From 61d1388f5d1bc71183bd06e0acba025c6d096392 Mon Sep 17 00:00:00 2001 From: Alan Pryor Date: Sat, 18 May 2019 13:00:13 -0400 Subject: [PATCH] Add skeleton app with namespaces --- .gitignore | 2 +- README.md | 8 +- app/__init__.py | 7 +- app/__init__test.py | 3 +- app/client/requests_test.py | 16 ---- app/config.py | 11 ++- app/fizz/__init__.py | 10 +++ app/fizz/fizzbar/__init__.py | 2 + app/fizz/fizzbar/controller.py | 56 ++++++++++++ app/fizz/fizzbar/controller_test.py | 84 ++++++++++++++++++ app/fizz/fizzbar/interface.py | 7 ++ app/fizz/fizzbar/model.py | 19 +++++ app/fizz/fizzbar/model_test.py | 22 +++++ app/fizz/fizzbar/schema.py | 9 ++ .../fizzbar/schema_test.py} | 0 app/fizz/fizzbar/service.py | 41 +++++++++ app/fizz/fizzbar/service_test.py | 60 +++++++++++++ app/fizz/fizzbaz/__init__.py | 2 + app/fizz/fizzbaz/controller.py | 56 ++++++++++++ app/fizz/fizzbaz/controller_test.py | 84 ++++++++++++++++++ app/fizz/fizzbaz/interface.py | 7 ++ app/fizz/fizzbaz/model.py | 19 +++++ app/fizz/fizzbaz/model_test.py | 22 +++++ app/fizz/fizzbaz/schema.py | 9 ++ app/fizz/fizzbaz/schema_test.py | 0 app/fizz/fizzbaz/service.py | 41 +++++++++ app/fizz/fizzbaz/service_test.py | 60 +++++++++++++ app/routes.py | 7 ++ app/test/fixtures.py | 4 +- app/widget/__init__.py | 9 ++ app/widget/controller.py | 56 ++++++++++++ app/widget/controller_test.py | 85 +++++++++++++++++++ app/widget/interface.py | 7 ++ app/widget/model.py | 19 +++++ app/widget/model_test.py | 22 +++++ app/widget/schema.py | 9 ++ app/widget/schema_test.py | 0 app/widget/service.py | 41 +++++++++ app/widget/service_test.py | 60 +++++++++++++ commands/__init__.py | 3 + commands/seed_command.py | 40 +++++++++ manage.py | 35 ++++++++ wsgi.py | 7 ++ 43 files changed, 1031 insertions(+), 30 deletions(-) delete mode 100644 app/client/requests_test.py create mode 100644 app/fizz/__init__.py create mode 100644 app/fizz/fizzbar/__init__.py create mode 100644 app/fizz/fizzbar/controller.py create mode 100644 app/fizz/fizzbar/controller_test.py create mode 100644 app/fizz/fizzbar/interface.py create mode 100644 app/fizz/fizzbar/model.py create mode 100644 app/fizz/fizzbar/model_test.py create mode 100644 app/fizz/fizzbar/schema.py rename app/{client/__init__.py => fizz/fizzbar/schema_test.py} (100%) create mode 100644 app/fizz/fizzbar/service.py create mode 100644 app/fizz/fizzbar/service_test.py create mode 100644 app/fizz/fizzbaz/__init__.py create mode 100644 app/fizz/fizzbaz/controller.py create mode 100644 app/fizz/fizzbaz/controller_test.py create mode 100644 app/fizz/fizzbaz/interface.py create mode 100644 app/fizz/fizzbaz/model.py create mode 100644 app/fizz/fizzbaz/model_test.py create mode 100644 app/fizz/fizzbaz/schema.py create mode 100644 app/fizz/fizzbaz/schema_test.py create mode 100644 app/fizz/fizzbaz/service.py create mode 100644 app/fizz/fizzbaz/service_test.py create mode 100644 app/routes.py create mode 100644 app/widget/__init__.py create mode 100644 app/widget/controller.py create mode 100644 app/widget/controller_test.py create mode 100644 app/widget/interface.py create mode 100644 app/widget/model.py create mode 100644 app/widget/model_test.py create mode 100644 app/widget/schema.py create mode 100644 app/widget/schema_test.py create mode 100644 app/widget/service.py create mode 100644 app/widget/service_test.py create mode 100644 commands/__init__.py create mode 100644 commands/seed_command.py create mode 100644 manage.py create mode 100644 wsgi.py diff --git a/.gitignore b/.gitignore index 131955a..3b3f5c7 100644 --- a/.gitignore +++ b/.gitignore @@ -119,4 +119,4 @@ venv.bak/ misc/ .Rproj.user -fte/app-test.db +app/app-test.db diff --git a/README.md b/README.md index 022268e..558eb9d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -# flask_testing_examples +# flask_api_example -A collection of examples for testing Flask applications - -## Running the tests - -All tests in this repository can be run by installing PyTest with `pip install pytest` and invoking the command `pytest` +A sample project showing how to build a scalable, maintainable, modular Flask API with a heavy emphasis on testing. diff --git a/app/__init__.py b/app/__init__.py index 3150c72..864dbdf 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,14 +1,19 @@ from flask import Flask, jsonify from flask_sqlalchemy import SQLAlchemy +from flask_restplus import Api db = SQLAlchemy() def create_app(env=None): - from fte.config import config_by_name + from app.config import config_by_name + from app.routes import register_routes + app = Flask(__name__) app.config.from_object(config_by_name[env or 'test']) + api = Api(app, title='Flaskerific API', version='0.1.0') + register_routes(api, app) db.init_app(app) @app.route('/health') diff --git a/app/__init__test.py b/app/__init__test.py index 8dd503c..6ab181b 100644 --- a/app/__init__test.py +++ b/app/__init__test.py @@ -1,4 +1,4 @@ -from fte.test.fixtures import app, client # noqa +from app.test.fixtures import app, client # noqa def test_app_creates(app): # noqa @@ -6,7 +6,6 @@ def test_app_creates(app): # noqa def test_app_healthy(app, client): # noqa - #with app.app_context(): with client: resp = client.get('/health') assert resp.status_code == 200 diff --git a/app/client/requests_test.py b/app/client/requests_test.py deleted file mode 100644 index 34e8af7..0000000 --- a/app/client/requests_test.py +++ /dev/null @@ -1,16 +0,0 @@ -from fte import create_app -from fte.decorators import accepts - -from fte.test.fixtures import app, client # noqa - - -def test_reqparse(app): - @app.route('/hello_world') - def respond(): - from flask import jsonify - return jsonify('Hello, World') - with app.test_client() as cl: - response = cl.get('/hello_world') - - assert response.status_code == 200 - assert response.json == 'Hello, World' diff --git a/app/config.py b/app/config.py index 50de454..3217831 100644 --- a/app/config.py +++ b/app/config.py @@ -1,18 +1,22 @@ import os +from typing import List, Type basedir = os.path.abspath(os.path.dirname(__file__)) class BaseConfig: + CONFIG_NAME = 'base' USE_MOCK_EQUIVALENCY = False DEBUG = False + SQLALCHEMY_TRACK_MODIFICATIONS = False class DevelopmentConfig(BaseConfig): CONFIG_NAME = 'dev' SECRET_KEY = os.getenv( - "DEV_SECRET_KEY", "You can't see California without Marlon Brando's eyes") + "DEV_SECRET_KEY", "You can't see California without Marlon Widgeto's eyes") DEBUG = True + SQLALCHEMY_TRACK_MODIFICATIONS = False TESTING = False SQLALCHEMY_DATABASE_URI = "sqlite:///{0}/app-dev.db".format(basedir) @@ -21,6 +25,7 @@ class TestingConfig(BaseConfig): CONFIG_NAME = 'test' SECRET_KEY = os.getenv("TEST_SECRET_KEY", "Thanos did nothing wrong") DEBUG = True + SQLALCHEMY_TRACK_MODIFICATIONS = False TESTING = True SQLALCHEMY_DATABASE_URI = "sqlite:///{0}/app-test.db".format(basedir) @@ -29,9 +34,11 @@ class ProductionConfig(BaseConfig): CONFIG_NAME = 'prod' SECRET_KEY = os.getenv("PROD_SECRET_KEY", "I'm Ron Burgundy?") DEBUG = False + SQLALCHEMY_TRACK_MODIFICATIONS = False TESTING = False SQLALCHEMY_DATABASE_URI = "sqlite:///{0}/app-prod.db".format(basedir) -EXPORT_CONFIGS = [DevelopmentConfig, TestingConfig, ProductionConfig] +EXPORT_CONFIGS: List[Type[BaseConfig]] = [ + DevelopmentConfig, TestingConfig, ProductionConfig] config_by_name = {cfg.CONFIG_NAME: cfg for cfg in EXPORT_CONFIGS} diff --git a/app/fizz/__init__.py b/app/fizz/__init__.py new file mode 100644 index 0000000..1540b52 --- /dev/null +++ b/app/fizz/__init__.py @@ -0,0 +1,10 @@ +BASE_ROUTE = 'fizz' + + +def register_routes(root_api, root='api'): + from .fizzbar.controller import api as fizzbar_api + from .fizzbaz.controller import api as fizzbaz_api + + root_api.add_namespace(fizzbar_api, path=f'/{root}/{BASE_ROUTE}/fizzbar') + root_api.add_namespace(fizzbaz_api, path=f'/{root}/{BASE_ROUTE}/fizzbaz') + return root_api diff --git a/app/fizz/fizzbar/__init__.py b/app/fizz/fizzbar/__init__.py new file mode 100644 index 0000000..a61d6bd --- /dev/null +++ b/app/fizz/fizzbar/__init__.py @@ -0,0 +1,2 @@ +from .model import Fizzbar # noqa +from .schema import FizzbarSchema # noqa diff --git a/app/fizz/fizzbar/controller.py b/app/fizz/fizzbar/controller.py new file mode 100644 index 0000000..f52f4cd --- /dev/null +++ b/app/fizz/fizzbar/controller.py @@ -0,0 +1,56 @@ +from flask import request +from flask_accepts import accepts, responds +from flask_restplus import Namespace, Resource +from flask.wrappers import Response +from typing import List + +from .schema import FizzbarSchema +from .service import FizzbarService +from .model import Fizzbar +from .interface import FizzbarInterface + +api = Namespace('Fizzbar', description='A modular namespace within fizz') # noqa + + +@api.route('/') +class FizzbarResource(Resource): + '''Fizzbars''' + + @responds(schema=FizzbarSchema, many=True) + def get(self) -> List[Fizzbar]: + '''Get all Fizzbars''' + + return FizzbarService.get_all() + + @accepts(schema=FizzbarSchema, api=api) + @responds(schema=FizzbarSchema) + def post(self) -> Fizzbar: + '''Create a Single Fizzbar''' + + return FizzbarService.create(request.parsed_obj) + + +@api.route('/') +@api.param('fizzbarId', 'Fizzbar database ID') +class FizzbarIdResource(Resource): + @responds(schema=FizzbarSchema) + def get(self, fizzbarId: int) -> Fizzbar: + '''Get Single Fizzbar''' + + return FizzbarService.get_by_id(fizzbarId) + + def delete(self, fizzbarId: int) -> Response: + '''Delete Single Fizzbar''' + from flask import jsonify + print('fizzbarId = ', fizzbarId) + id = FizzbarService.delete_by_id(fizzbarId) + return jsonify(dict(status='Success', id=id)) + + @accepts(schema=FizzbarSchema, api=api) + @responds(schema=FizzbarSchema) + def put(self, fizzbarId: int) -> Fizzbar: + '''Update Single Fizzbar''' + + changes: FizzbarInterface = request.parsed_obj + Fizzbar = FizzbarService.get_by_id(fizzbarId) + return FizzbarService.update(Fizzbar, changes) diff --git a/app/fizz/fizzbar/controller_test.py b/app/fizz/fizzbar/controller_test.py new file mode 100644 index 0000000..d195c74 --- /dev/null +++ b/app/fizz/fizzbar/controller_test.py @@ -0,0 +1,84 @@ + +from unittest.mock import patch +from flask.testing import FlaskClient + +from app.test.fixtures import client, app # noqa +from .service import FizzbarService +from .schema import FizzbarSchema +from .model import Fizzbar +from .interface import FizzbarInterface +from .. import BASE_ROUTE + + +def make_fizzbar(id: int = 123, name: str = 'Test fizzbar', + purpose: str = 'Test purpose') -> Fizzbar: + return Fizzbar( + fizzbar_id=id, name=name, purpose=purpose + ) + + +class TestFizzbarResource: + @patch.object(FizzbarService, 'get_all', + lambda: [make_fizzbar(123, name='Test Fizzbar 1'), + make_fizzbar(456, name='Test Fizzbar 2')]) + def test_get(self, client: FlaskClient): # noqa + with client: + results = client.get(f'/api/{BASE_ROUTE}/fizzbar', + follow_redirects=True).get_json() + expected = FizzbarSchema(many=True).dump( + [make_fizzbar(123, name='Test Fizzbar 1'), + make_fizzbar(456, name='Test Fizzbar 2')] + ).data + for r in results: + assert r in expected + + @patch.object(FizzbarService, 'create', + lambda create_request: Fizzbar(**create_request)) + def test_post(self, client: FlaskClient): # noqa + with client: + + payload = dict(name='Test fizzbar', purpose='Test purpose') + result = client.post(f'/api/{BASE_ROUTE}/fizzbar/', json=payload).get_json() + expected = FizzbarSchema().dump(Fizzbar( + name=payload['name'], + purpose=payload['purpose'], + )).data + assert result == expected + + +def fake_update(fizzbar: Fizzbar, changes: FizzbarInterface) -> Fizzbar: + # To fake an update, just return a new object + updated_Fizzbar = Fizzbar(fizzbar_id=fizzbar.fizzbar_id, + name=changes['name'], + purpose=changes['purpose']) + return updated_Fizzbar + + +class TestFizzbarIdResource: + @patch.object(FizzbarService, 'get_by_id', + lambda id: make_fizzbar(id=id)) + def test_get(self, client: FlaskClient): # noqa + with client: + result = client.get(f'/api/{BASE_ROUTE}/fizzbar/123').get_json() + expected = Fizzbar(fizzbar_id=123) + assert result['fizzbarId'] == expected.fizzbar_id + + @patch.object(FizzbarService, 'delete_by_id', + lambda id: [id]) + def test_delete(self, client: FlaskClient): # noqa + with client: + result = client.delete(f'/api/{BASE_ROUTE}/fizzbar/123').get_json() + expected = dict(status='Success', id=[123]) + assert result == expected + + @patch.object(FizzbarService, 'get_by_id', + lambda id: make_fizzbar(id=id)) + @patch.object(FizzbarService, 'update', fake_update) + def test_put(self, client: FlaskClient): # noqa + with client: + result = client.put(f'/api/{BASE_ROUTE}/fizzbar/123', + json={'name': 'New Fizzbar', + 'purpose': 'New purpose'}).get_json() + expected = FizzbarSchema().dump( + Fizzbar(fizzbar_id=123, name='New Fizzbar', purpose='New purpose')).data + assert result == expected diff --git a/app/fizz/fizzbar/interface.py b/app/fizz/fizzbar/interface.py new file mode 100644 index 0000000..903e6be --- /dev/null +++ b/app/fizz/fizzbar/interface.py @@ -0,0 +1,7 @@ +from mypy_extensions import TypedDict + + +class FizzbarInterface(TypedDict, total=False): + fizzbar_id: int + name: str + purpose: str diff --git a/app/fizz/fizzbar/model.py b/app/fizz/fizzbar/model.py new file mode 100644 index 0000000..b086921 --- /dev/null +++ b/app/fizz/fizzbar/model.py @@ -0,0 +1,19 @@ +from sqlalchemy import Integer, Column, String +from app import db # noqa +from .interface import FizzbarInterface +from typing import Any + + +class Fizzbar(db.Model): # type: ignore + '''A snazzy Fizzbar''' + + __tablename__ = 'fizzbar' + + fizzbar_id = Column(Integer(), primary_key=True) + name = Column(String(255)) + purpose = Column(String(255)) + + def update(self, changes: FizzbarInterface): + for key, val in changes.items(): + setattr(self, key, val) + return self diff --git a/app/fizz/fizzbar/model_test.py b/app/fizz/fizzbar/model_test.py new file mode 100644 index 0000000..defb472 --- /dev/null +++ b/app/fizz/fizzbar/model_test.py @@ -0,0 +1,22 @@ +from pytest import fixture +from flask_sqlalchemy import SQLAlchemy +from app.test.fixtures import app, db # noqa +from .model import Fizzbar + + +@fixture +def fizzbar() -> Fizzbar: + return Fizzbar( + fizzbar_id=1, name='Test fizzbar', purpose='Test purpose' + ) + + +def test_Fizzbar_create(fizzbar: Fizzbar): + assert fizzbar + + +def test_Fizzbar_retrieve(fizzbar: Fizzbar, db: SQLAlchemy): # noqa + db.session.add(fizzbar) + db.session.commit() + s = Fizzbar.query.first() + assert s.__dict__ == fizzbar.__dict__ diff --git a/app/fizz/fizzbar/schema.py b/app/fizz/fizzbar/schema.py new file mode 100644 index 0000000..10d79a5 --- /dev/null +++ b/app/fizz/fizzbar/schema.py @@ -0,0 +1,9 @@ +from marshmallow import fields, Schema + + +class FizzbarSchema(Schema): + '''Fizzbar schema''' + + fizzbarId = fields.Number(attribute='fizzbar_id') + name = fields.String(attribute='name') + purpose = fields.String(attribute='purpose') diff --git a/app/client/__init__.py b/app/fizz/fizzbar/schema_test.py similarity index 100% rename from app/client/__init__.py rename to app/fizz/fizzbar/schema_test.py diff --git a/app/fizz/fizzbar/service.py b/app/fizz/fizzbar/service.py new file mode 100644 index 0000000..28a799c --- /dev/null +++ b/app/fizz/fizzbar/service.py @@ -0,0 +1,41 @@ +from app import db +from typing import List +from .model import Fizzbar +from .interface import FizzbarInterface + + +class FizzbarService(): + @staticmethod + def get_all() -> List[Fizzbar]: + return Fizzbar.query.all() + + @staticmethod + def get_by_id(fizzbar_id: int) -> Fizzbar: + return Fizzbar.query.get(fizzbar_id) + + @staticmethod + def update(fizzbar: Fizzbar, Fizzbar_change_updates: FizzbarInterface) -> Fizzbar: + fizzbar.update(Fizzbar_change_updates) + db.session.commit() + return fizzbar + + @staticmethod + def delete_by_id(fizzbar_id: int) -> List[int]: + fizzbar = Fizzbar.query.filter(Fizzbar.fizzbar_id == fizzbar_id).first() + if not fizzbar: + return [] + db.session.delete(fizzbar) + db.session.commit() + return [fizzbar_id] + + @staticmethod + def create(new_attrs: FizzbarInterface) -> Fizzbar: + new_fizzbar = Fizzbar( + name=new_attrs['name'], + purpose=new_attrs['purpose'] + ) + + db.session.add(new_fizzbar) + db.session.commit() + + return new_fizzbar diff --git a/app/fizz/fizzbar/service_test.py b/app/fizz/fizzbar/service_test.py new file mode 100644 index 0000000..c3c4c76 --- /dev/null +++ b/app/fizz/fizzbar/service_test.py @@ -0,0 +1,60 @@ +from flask_sqlalchemy import SQLAlchemy +from typing import List +from app.test.fixtures import app, db # noqa +from .model import Fizzbar +from .service import FizzbarService # noqa +from .interface import FizzbarInterface + + +def test_get_all(db: SQLAlchemy): # noqa + yin: Fizzbar = Fizzbar(fizzbar_id=1, name='Yin', purpose='thing 1') + yang: Fizzbar = Fizzbar(fizzbar_id=2, name='Yang', purpose='thing 2') + db.session.add(yin) + db.session.add(yang) + db.session.commit() + + results: List[Fizzbar] = FizzbarService.get_all() + + assert len(results) == 2 + assert yin in results and yang in results + + +def test_update(db: SQLAlchemy): # noqa + yin: Fizzbar = Fizzbar(fizzbar_id=1, name='Yin', purpose='thing 1') + + db.session.add(yin) + db.session.commit() + updates: FizzbarInterface = dict(name='New Fizzbar name') + + FizzbarService.update(yin, updates) + + result: Fizzbar = Fizzbar.query.get(yin.fizzbar_id) + assert result.name == 'New Fizzbar name' + + +def test_delete_by_id(db: SQLAlchemy): # noqa + yin: Fizzbar = Fizzbar(fizzbar_id=1, name='Yin', purpose='thing 1') + yang: Fizzbar = Fizzbar(fizzbar_id=2, name='Yang', purpose='thing 2') + db.session.add(yin) + db.session.add(yang) + db.session.commit() + + FizzbarService.delete_by_id(1) + db.session.commit() + + results: List[Fizzbar] = Fizzbar.query.all() + + assert len(results) == 1 + assert yin not in results and yang in results + + +def test_create(db: SQLAlchemy): # noqa + + yin: FizzbarInterface = dict(name='Fancy new fizzbar', purpose='Fancy new purpose') + FizzbarService.create(yin) + results: List[Fizzbar] = Fizzbar.query.all() + + assert len(results) == 1 + + for k in yin.keys(): + assert getattr(results[0], k) == yin[k] diff --git a/app/fizz/fizzbaz/__init__.py b/app/fizz/fizzbaz/__init__.py new file mode 100644 index 0000000..5d3cab4 --- /dev/null +++ b/app/fizz/fizzbaz/__init__.py @@ -0,0 +1,2 @@ +from .model import Fizzbaz # noqa +from .schema import FizzbazSchema # noqa diff --git a/app/fizz/fizzbaz/controller.py b/app/fizz/fizzbaz/controller.py new file mode 100644 index 0000000..9d4c606 --- /dev/null +++ b/app/fizz/fizzbaz/controller.py @@ -0,0 +1,56 @@ +from flask import request +from flask_accepts import accepts, responds +from flask_restplus import Namespace, Resource +from flask.wrappers import Response +from typing import List + +from .schema import FizzbazSchema +from .service import FizzbazService +from .model import Fizzbaz +from .interface import FizzbazInterface + +api = Namespace('Fizzbaz', description='A modular namespace within fizz') # noqa + + +@api.route('/') +class FizzbazResource(Resource): + '''Fizzbazs''' + + @responds(schema=FizzbazSchema, many=True) + def get(self) -> List[Fizzbaz]: + '''Get all Fizzbazs''' + + return FizzbazService.get_all() + + @accepts(schema=FizzbazSchema, api=api) + @responds(schema=FizzbazSchema) + def post(self) -> Fizzbaz: + '''Create a Single Fizzbaz''' + + return FizzbazService.create(request.parsed_obj) + + +@api.route('/') +@api.param('fizzbazId', 'Fizzbaz database ID') +class FizzbazIdResource(Resource): + @responds(schema=FizzbazSchema) + def get(self, fizzbazId: int) -> Fizzbaz: + '''Get Single Fizzbaz''' + + return FizzbazService.get_by_id(fizzbazId) + + def delete(self, fizzbazId: int) -> Response: + '''Delete Single Fizzbaz''' + from flask import jsonify + + id = FizzbazService.delete_by_id(fizzbazId) + return jsonify(dict(status='Success', id=id)) + + @accepts(schema=FizzbazSchema, api=api) + @responds(schema=FizzbazSchema) + def put(self, fizzbazId: int) -> Fizzbaz: + '''Update Single Fizzbaz''' + + changes: FizzbazInterface = request.parsed_obj + Fizzbaz = FizzbazService.get_by_id(fizzbazId) + return FizzbazService.update(Fizzbaz, changes) diff --git a/app/fizz/fizzbaz/controller_test.py b/app/fizz/fizzbaz/controller_test.py new file mode 100644 index 0000000..edccf2b --- /dev/null +++ b/app/fizz/fizzbaz/controller_test.py @@ -0,0 +1,84 @@ + +from unittest.mock import patch +from flask.testing import FlaskClient + +from app.test.fixtures import client, app # noqa +from .service import FizzbazService +from .schema import FizzbazSchema +from .model import Fizzbaz +from .interface import FizzbazInterface +from .. import BASE_ROUTE + + +def make_fizzbaz(id: int = 123, name: str = 'Test fizzbaz', + purpose: str = 'Test purpose') -> Fizzbaz: + return Fizzbaz( + fizzbaz_id=id, name=name, purpose=purpose + ) + + +class TestFizzbazResource: + @patch.object(FizzbazService, 'get_all', + lambda: [make_fizzbaz(123, name='Test Fizzbaz 1'), + make_fizzbaz(456, name='Test Fizzbaz 2')]) + def test_get(self, client: FlaskClient): # noqa + with client: + results = client.get(f'/api/{BASE_ROUTE}/fizzbaz', + follow_redirects=True).get_json() + expected = FizzbazSchema(many=True).dump( + [make_fizzbaz(123, name='Test Fizzbaz 1'), + make_fizzbaz(456, name='Test Fizzbaz 2')] + ).data + for r in results: + assert r in expected + + @patch.object(FizzbazService, 'create', + lambda create_request: Fizzbaz(**create_request)) + def test_post(self, client: FlaskClient): # noqa + with client: + + payload = dict(name='Test fizzbaz', purpose='Test purpose') + result = client.post(f'/api/{BASE_ROUTE}/fizzbaz/', json=payload).get_json() + expected = FizzbazSchema().dump(Fizzbaz( + name=payload['name'], + purpose=payload['purpose'], + )).data + assert result == expected + + +def fake_update(fizzbaz: Fizzbaz, changes: FizzbazInterface) -> Fizzbaz: + # To fake an update, just return a new object + updated_Fizzbaz = Fizzbaz(fizzbaz_id=fizzbaz.fizzbaz_id, + name=changes['name'], + purpose=changes['purpose']) + return updated_Fizzbaz + + +class TestFizzbazIdResource: + @patch.object(FizzbazService, 'get_by_id', + lambda id: make_fizzbaz(id=id)) + def test_get(self, client: FlaskClient): # noqa + with client: + result = client.get(f'/api/{BASE_ROUTE}/fizzbaz/123').get_json() + expected = Fizzbaz(fizzbaz_id=123) + assert result['fizzbazId'] == expected.fizzbaz_id + + @patch.object(FizzbazService, 'delete_by_id', + lambda id: [id]) + def test_delete(self, client: FlaskClient): # noqa + with client: + result = client.delete(f'/api/{BASE_ROUTE}/fizzbaz/123').get_json() + expected = dict(status='Success', id=[123]) + assert result == expected + + @patch.object(FizzbazService, 'get_by_id', + lambda id: make_fizzbaz(id=id)) + @patch.object(FizzbazService, 'update', fake_update) + def test_put(self, client: FlaskClient): # noqa + with client: + result = client.put(f'/api/{BASE_ROUTE}/fizzbaz/123', + json={'name': 'New Fizzbaz', + 'purpose': 'New purpose'}).get_json() + expected = FizzbazSchema().dump( + Fizzbaz(fizzbaz_id=123, name='New Fizzbaz', purpose='New purpose')).data + assert result == expected diff --git a/app/fizz/fizzbaz/interface.py b/app/fizz/fizzbaz/interface.py new file mode 100644 index 0000000..751d698 --- /dev/null +++ b/app/fizz/fizzbaz/interface.py @@ -0,0 +1,7 @@ +from mypy_extensions import TypedDict + + +class FizzbazInterface(TypedDict, total=False): + fizzbaz_id: int + name: str + purpose: str diff --git a/app/fizz/fizzbaz/model.py b/app/fizz/fizzbaz/model.py new file mode 100644 index 0000000..e3d6142 --- /dev/null +++ b/app/fizz/fizzbaz/model.py @@ -0,0 +1,19 @@ +from sqlalchemy import Integer, Column, String +from app import db # noqa +from .interface import FizzbazInterface +from typing import Any + + +class Fizzbaz(db.Model): # type: ignore + '''A snazzy Fizzbaz''' + + __tablename__ = 'fizzbaz' + + fizzbaz_id = Column(Integer(), primary_key=True) + name = Column(String(255)) + purpose = Column(String(255)) + + def update(self, changes: FizzbazInterface): + for key, val in changes.items(): + setattr(self, key, val) + return self diff --git a/app/fizz/fizzbaz/model_test.py b/app/fizz/fizzbaz/model_test.py new file mode 100644 index 0000000..7c0fd09 --- /dev/null +++ b/app/fizz/fizzbaz/model_test.py @@ -0,0 +1,22 @@ +from pytest import fixture +from flask_sqlalchemy import SQLAlchemy +from app.test.fixtures import app, db # noqa +from .model import Fizzbaz + + +@fixture +def fizzbaz() -> Fizzbaz: + return Fizzbaz( + fizzbaz_id=1, name='Test fizzbaz', purpose='Test purpose' + ) + + +def test_Fizzbaz_create(fizzbaz: Fizzbaz): + assert fizzbaz + + +def test_Fizzbaz_retrieve(fizzbaz: Fizzbaz, db: SQLAlchemy): # noqa + db.session.add(fizzbaz) + db.session.commit() + s = Fizzbaz.query.first() + assert s.__dict__ == fizzbaz.__dict__ diff --git a/app/fizz/fizzbaz/schema.py b/app/fizz/fizzbaz/schema.py new file mode 100644 index 0000000..104ba35 --- /dev/null +++ b/app/fizz/fizzbaz/schema.py @@ -0,0 +1,9 @@ +from marshmallow import fields, Schema + + +class FizzbazSchema(Schema): + '''Fizzbaz schema''' + + fizzbazId = fields.Number(attribute='fizzbaz_id') + name = fields.String(attribute='name') + purpose = fields.String(attribute='purpose') diff --git a/app/fizz/fizzbaz/schema_test.py b/app/fizz/fizzbaz/schema_test.py new file mode 100644 index 0000000..e69de29 diff --git a/app/fizz/fizzbaz/service.py b/app/fizz/fizzbaz/service.py new file mode 100644 index 0000000..2454471 --- /dev/null +++ b/app/fizz/fizzbaz/service.py @@ -0,0 +1,41 @@ +from app import db +from typing import List +from .model import Fizzbaz +from .interface import FizzbazInterface + + +class FizzbazService(): + @staticmethod + def get_all() -> List[Fizzbaz]: + return Fizzbaz.query.all() + + @staticmethod + def get_by_id(fizzbaz_id: int) -> Fizzbaz: + return Fizzbaz.query.get(fizzbaz_id) + + @staticmethod + def update(fizzbaz: Fizzbaz, Fizzbaz_change_updates: FizzbazInterface) -> Fizzbaz: + fizzbaz.update(Fizzbaz_change_updates) + db.session.commit() + return fizzbaz + + @staticmethod + def delete_by_id(fizzbaz_id: int) -> List[int]: + fizzbaz = Fizzbaz.query.filter(Fizzbaz.fizzbaz_id == fizzbaz_id).first() + if not fizzbaz: + return [] + db.session.delete(fizzbaz) + db.session.commit() + return [fizzbaz_id] + + @staticmethod + def create(new_attrs: FizzbazInterface) -> Fizzbaz: + new_fizzbaz = Fizzbaz( + name=new_attrs['name'], + purpose=new_attrs['purpose'] + ) + + db.session.add(new_fizzbaz) + db.session.commit() + + return new_fizzbaz diff --git a/app/fizz/fizzbaz/service_test.py b/app/fizz/fizzbaz/service_test.py new file mode 100644 index 0000000..7b3d477 --- /dev/null +++ b/app/fizz/fizzbaz/service_test.py @@ -0,0 +1,60 @@ +from flask_sqlalchemy import SQLAlchemy +from typing import List +from app.test.fixtures import app, db # noqa +from .model import Fizzbaz +from .service import FizzbazService # noqa +from .interface import FizzbazInterface + + +def test_get_all(db: SQLAlchemy): # noqa + yin: Fizzbaz = Fizzbaz(fizzbaz_id=1, name='Yin', purpose='thing 1') + yang: Fizzbaz = Fizzbaz(fizzbaz_id=2, name='Yang', purpose='thing 2') + db.session.add(yin) + db.session.add(yang) + db.session.commit() + + results: List[Fizzbaz] = FizzbazService.get_all() + + assert len(results) == 2 + assert yin in results and yang in results + + +def test_update(db: SQLAlchemy): # noqa + yin: Fizzbaz = Fizzbaz(fizzbaz_id=1, name='Yin', purpose='thing 1') + + db.session.add(yin) + db.session.commit() + updates: FizzbazInterface = dict(name='New Fizzbaz name') + + FizzbazService.update(yin, updates) + + result: Fizzbaz = Fizzbaz.query.get(yin.fizzbaz_id) + assert result.name == 'New Fizzbaz name' + + +def test_delete_by_id(db: SQLAlchemy): # noqa + yin: Fizzbaz = Fizzbaz(fizzbaz_id=1, name='Yin', purpose='thing 1') + yang: Fizzbaz = Fizzbaz(fizzbaz_id=2, name='Yang', purpose='thing 2') + db.session.add(yin) + db.session.add(yang) + db.session.commit() + + FizzbazService.delete_by_id(1) + db.session.commit() + + results: List[Fizzbaz] = Fizzbaz.query.all() + + assert len(results) == 1 + assert yin not in results and yang in results + + +def test_create(db: SQLAlchemy): # noqa + + yin: FizzbazInterface = dict(name='Fancy new fizzbaz', purpose='Fancy new purpose') + FizzbazService.create(yin) + results: List[Fizzbaz] = Fizzbaz.query.all() + + assert len(results) == 1 + + for k in yin.keys(): + assert getattr(results[0], k) == yin[k] diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..847a699 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,7 @@ +def register_routes(api, app, root='api'): + from app.widget import register_routes as attach_widget + from app.fizz import register_routes as attach_fizz + + # Add routes + attach_widget(api) + attach_fizz(api) diff --git a/app/test/fixtures.py b/app/test/fixtures.py index 377de72..c6db006 100644 --- a/app/test/fixtures.py +++ b/app/test/fixtures.py @@ -1,6 +1,6 @@ import pytest -from fte import create_app +from app import create_app @pytest.fixture @@ -15,7 +15,7 @@ def client(app): @pytest.fixture def db(app): - from fte import db + from app import db with app.app_context(): db.create_all() yield db diff --git a/app/widget/__init__.py b/app/widget/__init__.py new file mode 100644 index 0000000..69f61bb --- /dev/null +++ b/app/widget/__init__.py @@ -0,0 +1,9 @@ +from .model import Widget # noqa +from .schema import WidgetSchema # noqa +BASE_ROUTE = 'widget' + + +def register_routes(root_api, root='api'): + from .controller import api as widget_api + root_api.add_namespace(widget_api, path=f'/{root}/{BASE_ROUTE}') + return root_api diff --git a/app/widget/controller.py b/app/widget/controller.py new file mode 100644 index 0000000..9485ff7 --- /dev/null +++ b/app/widget/controller.py @@ -0,0 +1,56 @@ +from flask import request +from flask_accepts import accepts, responds +from flask_restplus import Namespace, Resource +from flask.wrappers import Response +from typing import List + +from .schema import WidgetSchema +from .service import WidgetService +from .model import Widget +from .interface import WidgetInterface + +api = Namespace('Widget', description='Single namespace, single entity') # noqa + + +@api.route('/') +class WidgetResource(Resource): + '''Widgets''' + + @responds(schema=WidgetSchema, many=True) + def get(self) -> List[Widget]: + '''Get all Widgets''' + + return WidgetService.get_all() + + @accepts(schema=WidgetSchema, api=api) + @responds(schema=WidgetSchema) + def post(self) -> Widget: + '''Create a Single Widget''' + + return WidgetService.create(request.parsed_obj) + + +@api.route('/') +@api.param('widgetId', 'Widget database ID') +class WidgetIdResource(Resource): + @responds(schema=WidgetSchema) + def get(self, widgetId: int) -> Widget: + '''Get Single Widget''' + + return WidgetService.get_by_id(widgetId) + + def delete(self, widgetId: int) -> Response: + '''Delete Single Widget''' + from flask import jsonify + + id = WidgetService.delete_by_id(widgetId) + return jsonify(dict(status='Success', id=id)) + + @accepts(schema=WidgetSchema, api=api) + @responds(schema=WidgetSchema) + def put(self, widgetId: int) -> Widget: + '''Update Single Widget''' + + changes: WidgetInterface = request.parsed_obj + Widget = WidgetService.get_by_id(widgetId) + return WidgetService.update(Widget, changes) diff --git a/app/widget/controller_test.py b/app/widget/controller_test.py new file mode 100644 index 0000000..cf5506e --- /dev/null +++ b/app/widget/controller_test.py @@ -0,0 +1,85 @@ + +from unittest.mock import patch +from flask.testing import FlaskClient + +from app.test.fixtures import client, app # noqa +from .service import WidgetService +from .schema import WidgetSchema +from .model import Widget +from .interface import WidgetInterface +from . import BASE_ROUTE + + +def make_widget(id: int = 123, name: str = 'Test widget', + purpose: str = 'Test purpose') -> Widget: + return Widget( + widget_id=id, name=name, purpose=purpose + ) + + +class TestWidgetResource: + @patch.object(WidgetService, 'get_all', + lambda: [make_widget(123, name='Test Widget 1'), + make_widget(456, name='Test Widget 2')]) + def test_get(self, client: FlaskClient): # noqa + with client: + results = client.get(f'/api/{BASE_ROUTE}', + follow_redirects=True).get_json() + expected = WidgetSchema(many=True).dump( + [make_widget(123, name='Test Widget 1'), + make_widget(456, name='Test Widget 2')] + ).data + for r in results: + assert r in expected + + @patch.object(WidgetService, 'create', + lambda create_request: Widget(**create_request)) + def test_post(self, client: FlaskClient): # noqa + with client: + + payload = dict(name='Test widget', purpose='Test purpose') + result = client.post(f'/api/{BASE_ROUTE}/', json=payload).get_json() + expected = WidgetSchema().dump(Widget( + name=payload['name'], + purpose=payload['purpose'], + )).data + assert result == expected + + +def fake_update(widget: Widget, changes: WidgetInterface) -> Widget: + # To fake an update, just return a new object + updated_Widget = Widget(widget_id=widget.widget_id, + name=changes['name'], + purpose=changes['purpose']) + return updated_Widget + + +class TestWidgetIdResource: + @patch.object(WidgetService, 'get_by_id', + lambda id: make_widget(id=id)) + def test_get(self, client: FlaskClient): # noqa + with client: + result = client.get(f'/api/{BASE_ROUTE}/123').get_json() + expected = make_widget(id=123) + print(f'result = ', result) + assert result['widgetId'] == expected.widget_id + + @patch.object(WidgetService, 'delete_by_id', + lambda id: id) + def test_delete(self, client: FlaskClient): # noqa + with client: + result = client.delete(f'/api/{BASE_ROUTE}/123').get_json() + expected = dict(status='Success', id=123) + assert result == expected + + @patch.object(WidgetService, 'get_by_id', + lambda id: make_widget(id=id)) + @patch.object(WidgetService, 'update', fake_update) + def test_put(self, client: FlaskClient): # noqa + with client: + result = client.put(f'/api/{BASE_ROUTE}/123', + json={'name': 'New Widget', + 'purpose': 'New purpose'}).get_json() + expected = WidgetSchema().dump( + Widget(widget_id=123, name='New Widget', purpose='New purpose')).data + assert result == expected diff --git a/app/widget/interface.py b/app/widget/interface.py new file mode 100644 index 0000000..6c214bd --- /dev/null +++ b/app/widget/interface.py @@ -0,0 +1,7 @@ +from mypy_extensions import TypedDict + + +class WidgetInterface(TypedDict, total=False): + widget_id: int + name: str + purpose: str diff --git a/app/widget/model.py b/app/widget/model.py new file mode 100644 index 0000000..fbcd9a9 --- /dev/null +++ b/app/widget/model.py @@ -0,0 +1,19 @@ +from sqlalchemy import Integer, Column, String +from app import db # noqa +from .interface import WidgetInterface +from typing import Any + + +class Widget(db.Model): # type: ignore + '''A snazzy Widget''' + + __tablename__ = 'widget' + + widget_id = Column(Integer(), primary_key=True) + name = Column(String(255)) + purpose = Column(String(255)) + + def update(self, changes: WidgetInterface): + for key, val in changes.items(): + setattr(self, key, val) + return self diff --git a/app/widget/model_test.py b/app/widget/model_test.py new file mode 100644 index 0000000..5069d16 --- /dev/null +++ b/app/widget/model_test.py @@ -0,0 +1,22 @@ +from pytest import fixture +from flask_sqlalchemy import SQLAlchemy +from app.test.fixtures import app, db # noqa +from .model import Widget + + +@fixture +def widget() -> Widget: + return Widget( + widget_id=1, name='Test widget', purpose='Test purpose' + ) + + +def test_Widget_create(widget: Widget): + assert widget + + +def test_Widget_retrieve(widget: Widget, db: SQLAlchemy): # noqa + db.session.add(widget) + db.session.commit() + s = Widget.query.first() + assert s.__dict__ == widget.__dict__ diff --git a/app/widget/schema.py b/app/widget/schema.py new file mode 100644 index 0000000..f576bd9 --- /dev/null +++ b/app/widget/schema.py @@ -0,0 +1,9 @@ +from marshmallow import fields, Schema + + +class WidgetSchema(Schema): + '''Widget schema''' + + widgetId = fields.Number(attribute='widget_id') + name = fields.String(attribute='name') + purpose = fields.String(attribute='purpose') diff --git a/app/widget/schema_test.py b/app/widget/schema_test.py new file mode 100644 index 0000000..e69de29 diff --git a/app/widget/service.py b/app/widget/service.py new file mode 100644 index 0000000..ef50003 --- /dev/null +++ b/app/widget/service.py @@ -0,0 +1,41 @@ +from app import db +from typing import List +from .model import Widget +from .interface import WidgetInterface + + +class WidgetService(): + @staticmethod + def get_all() -> List[Widget]: + return Widget.query.all() + + @staticmethod + def get_by_id(widget_id: int) -> Widget: + return Widget.query.get(widget_id) + + @staticmethod + def update(widget: Widget, Widget_change_updates: WidgetInterface) -> Widget: + widget.update(Widget_change_updates) + db.session.commit() + return widget + + @staticmethod + def delete_by_id(widget_id: int) -> List[int]: + widget = Widget.query.filter(Widget.widget_id == widget_id).first() + if not widget: + return [] + db.session.delete(widget) + db.session.commit() + return [widget_id] + + @staticmethod + def create(new_attrs: WidgetInterface) -> Widget: + new_widget = Widget( + name=new_attrs['name'], + purpose=new_attrs['purpose'] + ) + + db.session.add(new_widget) + db.session.commit() + + return new_widget diff --git a/app/widget/service_test.py b/app/widget/service_test.py new file mode 100644 index 0000000..1c180e2 --- /dev/null +++ b/app/widget/service_test.py @@ -0,0 +1,60 @@ +from flask_sqlalchemy import SQLAlchemy +from typing import List +from app.test.fixtures import app, db # noqa +from .model import Widget +from .service import WidgetService # noqa +from .interface import WidgetInterface + + +def test_get_all(db: SQLAlchemy): # noqa + yin: Widget = Widget(widget_id=1, name='Yin', purpose='thing 1') + yang: Widget = Widget(widget_id=2, name='Yang', purpose='thing 2') + db.session.add(yin) + db.session.add(yang) + db.session.commit() + + results: List[Widget] = WidgetService.get_all() + + assert len(results) == 2 + assert yin in results and yang in results + + +def test_update(db: SQLAlchemy): # noqa + yin: Widget = Widget(widget_id=1, name='Yin', purpose='thing 1') + + db.session.add(yin) + db.session.commit() + updates: WidgetInterface = dict(name='New Widget name') + + WidgetService.update(yin, updates) + + result: Widget = Widget.query.get(yin.widget_id) + assert result.name == 'New Widget name' + + +def test_delete_by_id(db: SQLAlchemy): # noqa + yin: Widget = Widget(widget_id=1, name='Yin', purpose='thing 1') + yang: Widget = Widget(widget_id=2, name='Yang', purpose='thing 2') + db.session.add(yin) + db.session.add(yang) + db.session.commit() + + WidgetService.delete_by_id(1) + db.session.commit() + + results: List[Widget] = Widget.query.all() + + assert len(results) == 1 + assert yin not in results and yang in results + + +def test_create(db: SQLAlchemy): # noqa + + yin: WidgetInterface = dict(name='Fancy new widget', purpose='Fancy new purpose') + WidgetService.create(yin) + results: List[Widget] = Widget.query.all() + + assert len(results) == 1 + + for k in yin.keys(): + assert getattr(results[0], k) == yin[k] diff --git a/commands/__init__.py b/commands/__init__.py new file mode 100644 index 0000000..7f4d264 --- /dev/null +++ b/commands/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from .seed_command import SeedCommand diff --git a/commands/seed_command.py b/commands/seed_command.py new file mode 100644 index 0000000..f1be0ed --- /dev/null +++ b/commands/seed_command.py @@ -0,0 +1,40 @@ +from datetime import datetime +import pandas as pd +import numpy as np +from flask_script import Command + + +from app import db + + +def seed_widgets(): + from app.widget import Widget + widgets = [ + { + 'name': 'Pizza Slicer', + 'purpose': 'Cut delicious pizza', + }, + { + 'name': 'Rolling Pin', + 'purpose': 'Roll delicious pizza', + }, + { + 'name': 'Pizza Oven', + 'purpose': 'Bake delicious pizza', + }, + ] + db.session.bulk_insert_mappings(Widget, widgets) + + +class SeedCommand(Command): + """ Seed the DB.""" + + def run(self): + if input('ARE YOU SURE YOU WANT TO DROP ALL TABLES AND RECREATE? (Y/N)\n' + ).lower() == 'y': + print('Dropping tables...') + db.drop_all() + db.create_all() + seed_widgets() + db.session.commit() + print('Widgets successfully seeded.') diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..a836df0 --- /dev/null +++ b/manage.py @@ -0,0 +1,35 @@ +import os +from flask_script import Manager + +from app import create_app, db +from commands.seed_command import SeedCommand + +env = os.getenv('FLASK_ENV') or 'test' +print(f'Active environment: * {env} *') +app = create_app(env) + +manager = Manager(app) +app.app_context().push() +manager.add_command('seed_db', SeedCommand) + + +@manager.command +def run(): + app.run() + + +@manager.command +def init_db(): + print('Creating all resources.') + db.create_all() + + +@manager.command +def drop_all(): + if input('ARE YOU SURE YOU WANT TO DROP ALL TABLES? (Y/N)\n').lower() == 'y': + print('Dropping tables...') + db.drop_all() + + +if __name__ == '__main__': + manager.run() diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..11fa653 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,7 @@ +import os + +from app import create_app + +app = create_app(os.getenv('FLASK_ENV') or 'test') +if __name__ == '__main__': + app.run(debug=True)