From 50014ae1ff4ef70910595fdfecb5a5617487eab9 Mon Sep 17 00:00:00 2001 From: androiddrew Date: Sat, 11 May 2019 08:26:47 -0400 Subject: [PATCH] Initial commit --- .gitignore | 72 ++++++++++++++++ README.md | 3 + services/backend/.coveragerc | 6 ++ services/backend/.gitignore | 62 ++++++++++++++ services/backend/LICENSE | 10 +++ services/backend/README.md | 64 +++++++++++++++ services/backend/alembic.ini | 2 + services/backend/dev_requirements.in | 7 ++ services/backend/dev_requirements.txt | 29 +++++++ services/backend/manage.py | 82 +++++++++++++++++++ services/backend/migrations/README | 1 + services/backend/migrations/env.py | 21 +++++ services/backend/migrations/script.py.mako | 24 ++++++ services/backend/pulley/__init__.py | 0 services/backend/pulley/api/__init__.py | 0 services/backend/pulley/api/todo/__init__.py | 2 + services/backend/pulley/api/todo/manager.py | 80 ++++++++++++++++++ services/backend/pulley/api/todo/model.py | 21 +++++ services/backend/pulley/api/todo/view.py | 49 +++++++++++ .../backend/pulley/api/welcome/__init__.py | 1 + services/backend/pulley/api/welcome/views.py | 5 ++ services/backend/pulley/common.py | 31 +++++++ services/backend/pulley/db.py | 32 ++++++++ services/backend/pulley/error.py | 5 ++ services/backend/pulley/index.py | 82 +++++++++++++++++++ services/backend/pulley/logger.py | 29 +++++++ services/backend/pulley/manager.py | 32 ++++++++ services/backend/pulley/schema.py | 14 ++++ services/backend/pulley/settings.py | 12 +++ services/backend/pulley/settings.toml | 26 ++++++ services/backend/requirements.in | 7 ++ services/backend/requirements.txt | 23 ++++++ services/backend/scripts/bootstrap | 15 ++++ services/backend/setup.py | 15 ++++ services/backend/tests/__init__.py | 0 services/backend/tests/conftest.py | 51 ++++++++++++ services/backend/tests/test_app_routes.py | 55 +++++++++++++ services/backend/tests/test_renders.py | 15 ++++ 38 files changed, 985 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 services/backend/.coveragerc create mode 100644 services/backend/.gitignore create mode 100644 services/backend/LICENSE create mode 100644 services/backend/README.md create mode 100644 services/backend/alembic.ini create mode 100644 services/backend/dev_requirements.in create mode 100644 services/backend/dev_requirements.txt create mode 100644 services/backend/manage.py create mode 100644 services/backend/migrations/README create mode 100644 services/backend/migrations/env.py create mode 100644 services/backend/migrations/script.py.mako create mode 100644 services/backend/pulley/__init__.py create mode 100644 services/backend/pulley/api/__init__.py create mode 100644 services/backend/pulley/api/todo/__init__.py create mode 100644 services/backend/pulley/api/todo/manager.py create mode 100644 services/backend/pulley/api/todo/model.py create mode 100644 services/backend/pulley/api/todo/view.py create mode 100644 services/backend/pulley/api/welcome/__init__.py create mode 100644 services/backend/pulley/api/welcome/views.py create mode 100644 services/backend/pulley/common.py create mode 100644 services/backend/pulley/db.py create mode 100644 services/backend/pulley/error.py create mode 100644 services/backend/pulley/index.py create mode 100644 services/backend/pulley/logger.py create mode 100644 services/backend/pulley/manager.py create mode 100644 services/backend/pulley/schema.py create mode 100644 services/backend/pulley/settings.py create mode 100644 services/backend/pulley/settings.toml create mode 100644 services/backend/requirements.in create mode 100644 services/backend/requirements.txt create mode 100755 services/backend/scripts/bootstrap create mode 100644 services/backend/setup.py create mode 100644 services/backend/tests/__init__.py create mode 100644 services/backend/tests/conftest.py create mode 100644 services/backend/tests/test_app_routes.py create mode 100644 services/backend/tests/test_renders.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaa2310 --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Pycharm +.idea/ + +# MacOS +*.DS_Store + +# A scratch pad file you will not want to upload to the repo +NOTES.md + +.sass-cache/ + +media/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..47a3d5b --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Globalmantics + +An accounting solution powered by Vue and Molten. \ No newline at end of file diff --git a/services/backend/.coveragerc b/services/backend/.coveragerc new file mode 100644 index 0000000..5098577 --- /dev/null +++ b/services/backend/.coveragerc @@ -0,0 +1,6 @@ +[run] +source = pulley +omit = test* + +[report] +show_missing = True \ No newline at end of file diff --git a/services/backend/.gitignore b/services/backend/.gitignore new file mode 100644 index 0000000..31496a5 --- /dev/null +++ b/services/backend/.gitignore @@ -0,0 +1,62 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Pycharm +.idea \ No newline at end of file diff --git a/services/backend/LICENSE b/services/backend/LICENSE new file mode 100644 index 0000000..5592350 --- /dev/null +++ b/services/backend/LICENSE @@ -0,0 +1,10 @@ + +MIT License + +Copyright (c) 2019, Drew Bednar + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/services/backend/README.md b/services/backend/README.md new file mode 100644 index 0000000..8778d06 --- /dev/null +++ b/services/backend/README.md @@ -0,0 +1,64 @@ +# pulley + +Globalmantics restful api + +## First time setup + +Create a virtual environment and activate it. Now from the root project directory run `./scripts/bootstrap`. This will install `pip-tools` and sync any dependencies for the first time. + +To run the app you will need a [postgres] database. Create a development and a test database. Update the connection strings within the `pulley.settings.toml`. At this time, if you choose to, you can remove the demo `Todo` code and replace it with your own Model. Otherwise create your first [alembic] migration using the `alembic revision --autogenerate -m "your revision message"` command. Finally, apply your first migration with `alembic upgrade head`. + + +## Running the developement server +A `manage.py` script has been included with a collection of [click] cli functions to assist in development. + +__Note__: the developement server command is not a production webserver. You will need to c + +``` +python manage.py runserver +``` + +## Using the interactive interpreter +The `manage.py` script can be used to open an interactive interpreter with a configured molten application from your project. +``` +python manage.py shell +``` + +## Dependency management + +Dependencies are managed via [pip-tools]. + +### Adding a dependency + +To add a dependency, edit `requirements.in` (or `dev_requirements.in` +for dev dependencies) and add your dependency then run `pip-compile +requirements.in`. + +### Syncing dependencies + +Run `pip-sync requirements.txt dev_requirements.txt`. + + +## Migrations + +Migrations are managed using [alembic]. + +### Generating new migrations + + alembic revision --autogenerate -m 'message' + +### Running the migrations + + alembic upgrade head # to upgrade the local db + env ENVIRONMENT=test alembic upgrade head # to upgrade the test db + env ENVIRONMENT=prod alembic upgrade head # to upgrade prod + +## Testing + +Run the tests by invoking `py.test` in the project root. Make sure you +run any pending migrations beforehand. + +[alembic]: http://alembic.zzzcomputing.com/en/latest/ +[click]: https://click.palletsprojects.com +[pip-tools]: https://github.com/jazzband/pip-tools +[postgres]: https://www.postgresql.org/ \ No newline at end of file diff --git a/services/backend/alembic.ini b/services/backend/alembic.ini new file mode 100644 index 0000000..2c614a3 --- /dev/null +++ b/services/backend/alembic.ini @@ -0,0 +1,2 @@ +[alembic] +script_location = migrations \ No newline at end of file diff --git a/services/backend/dev_requirements.in b/services/backend/dev_requirements.in new file mode 100644 index 0000000..5a45a96 --- /dev/null +++ b/services/backend/dev_requirements.in @@ -0,0 +1,7 @@ +black +bumpversion +flake8 +pip-tools +pytest +pytest-cov +werkzeug \ No newline at end of file diff --git a/services/backend/dev_requirements.txt b/services/backend/dev_requirements.txt new file mode 100644 index 0000000..aaae50c --- /dev/null +++ b/services/backend/dev_requirements.txt @@ -0,0 +1,29 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile dev_requirements.in +# +--trusted-host pypi.python.org + +appdirs==1.4.3 # via black +atomicwrites==1.3.0 # via pytest +attrs==19.1.0 # via black, pytest +black==19.3b0 +bumpversion==0.5.3 +click==7.0 # via black, pip-tools +coverage==4.5.3 # via pytest-cov +entrypoints==0.3 # via flake8 +flake8==3.7.7 +mccabe==0.6.1 # via flake8 +more-itertools==7.0.0 # via pytest +pip-tools==3.7.0 +pluggy==0.11.0 # via pytest +py==1.8.0 # via pytest +pycodestyle==2.5.0 # via flake8 +pyflakes==2.1.1 # via flake8 +pytest-cov==2.7.1 +pytest==4.4.2 +six==1.12.0 # via pip-tools, pytest +toml==0.10.0 # via black +werkzeug==0.15.2 diff --git a/services/backend/manage.py b/services/backend/manage.py new file mode 100644 index 0000000..63ef951 --- /dev/null +++ b/services/backend/manage.py @@ -0,0 +1,82 @@ +import click +from molten.contrib.sqlalchemy import EngineData +from pulley.index import create_app + +_, app = create_app() + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.option("--host", "-h", default="0.0.0.0", help="A hostname or IP address") +@click.option( + "--port", "-p", default=8000, help="Port number to bind to development server" +) +def runserver(host, port): + """ + Runs a Werkzueg development server. Do no use for production. + """ + from werkzeug.serving import run_simple + + run_simple( + hostname=host, port=port, application=app, use_debugger=True, use_reloader=True + ) + + +@cli.command() +def shell(): + """ + Enters an interactive shell with an app instance and dependency resolver. + """ + import rlcompleter + import readline + from code import InteractiveConsole + + helpers = {"app": app, "resolver": app.injector.get_resolver()} + + readline.parse_and_bind("tab: complete") + interpreter = InteractiveConsole(helpers) + interpreter.interact(f"Instances in scope: {', '.join(helpers)}.", "") + + +@cli.command() +def initdb(): + """ + Initialize database + """ + click.echo("This feature has been commented out. Please use alembic to manage your database initialization and changes.") + # from pulley.db import Base + # + # def _init(engine_data: EngineData): + # Base.metadata.create_all(bind=engine_data.engine) + # + # click.echo("Creating database") + # app.injector.get_resolver().resolve(_init)() + # click.echo("Database created") + + +@cli.command() +def dropdb(): + """ + Drop all tables in database + """ + + from pulley.db import Base + + def _drop(engine_data: EngineData): + Base.metadata.drop_all(bind=engine_data.engine) + + click.echo("Are you sure you would like to drop the database?: [Y/N]") + response = input() + if response.lower() == "y": + app.injector.get_resolver().resolve(_drop)() + click.echo("Database dropped") + else: + click.echo("Database drop aborted") + + +if __name__ == "__main__": + cli() diff --git a/services/backend/migrations/README b/services/backend/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/services/backend/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/services/backend/migrations/env.py b/services/backend/migrations/env.py new file mode 100644 index 0000000..9bed778 --- /dev/null +++ b/services/backend/migrations/env.py @@ -0,0 +1,21 @@ +"""isort:skip_file +""" +import os +import sys; sys.path.append(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..")) # noqa + +from alembic import context +from pulley.index import create_app +from pulley.db import Base +from molten.contrib.sqlalchemy import EngineData + +_, app = create_app() + + +def run_migrations_online(engine_data: EngineData): + with engine_data.engine.connect() as connection: + context.configure(connection=connection, target_metadata=Base.metadata) + with context.begin_transaction(): + context.run_migrations() + + +app.injector.get_resolver().resolve(run_migrations_online)() \ No newline at end of file diff --git a/services/backend/migrations/script.py.mako b/services/backend/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/services/backend/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/services/backend/pulley/__init__.py b/services/backend/pulley/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/pulley/api/__init__.py b/services/backend/pulley/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/pulley/api/todo/__init__.py b/services/backend/pulley/api/todo/__init__.py new file mode 100644 index 0000000..caaebba --- /dev/null +++ b/services/backend/pulley/api/todo/__init__.py @@ -0,0 +1,2 @@ +from .manager import TodoManager, TodoManagerComponent +from .view import todo_routes \ No newline at end of file diff --git a/services/backend/pulley/api/todo/manager.py b/services/backend/pulley/api/todo/manager.py new file mode 100644 index 0000000..5bc5f70 --- /dev/null +++ b/services/backend/pulley/api/todo/manager.py @@ -0,0 +1,80 @@ +from inspect import Parameter +from typing import List +from molten import BaseApp, HTTPError, HTTP_409, HTTP_404 +from sqlalchemy.orm import Session + +from pulley.manager import BaseManager +from pulley.error import EntityNotFound +from .model import Todo, TodoModel + + +class TodoManager(BaseManager): + """A `TodoManager` is accountable for the CRUD operations associated with a `Todo` instance""" + + def schema_from_model(self, result: TodoModel) -> Todo: + _todo = Todo( + id=result.id, + href=self.app.reverse_uri("get_todo_by_id", todo_id=result.id), + createdDate=result.created_date, + modifiedDate=result.modified_date, + todo=result.todo, + complete=result.complete + ) + return _todo + + def model_from_schema(self, todo: Todo) -> TodoModel: + _todo_model = TodoModel( + todo=todo.todo, + complete=todo.complete + ) + return _todo_model + + def get_todos(self) -> List[Todo]: + """Retrieves a list of `Todo` representations""" + results = self.session.query(TodoModel).order_by(TodoModel.id).all() + todos = [self.schema_from_model(result) for result in results] + return todos + + def get_todo_by_id(self, id) -> Todo: + """Retrieves a `Todo` representation by id""" + result = self.session.query(TodoModel).filter_by(id=id).one_or_none() + if result is None: + raise EntityNotFound(f"Todo: {id} does not exist") + return self.schema_from_model(result) + + def create_todo(self, todo: Todo) -> Todo: + """Creates a new `Todo` resource and returns its representation""" + todo_model = self.model_from_schema(todo) + self.session.add(todo_model) + self.session.flush() + return self.schema_from_model(todo_model) + + def update_todo(self, todo_id: int, todo: Todo) -> Todo: + """Updates an existing `Todo` resource and returns its new representation""" + result = self.session.query(TodoModel).filter_by(id=todo_id).one_or_none() + if result is None: + raise EntityNotFound(f"Todo: {todo_id} does not exist") + updates = self.model_from_schema(todo) + updates.id = todo_id + self.session.merge(updates) + self.session.flush() + todo = self.schema_from_model(result) + return todo + + def delete_todo(self, id): + """Deletes a `Todo` """ + result = self.session.query(TodoModel).filter_by(id=id).one_or_none() + if result is not None: + self.session.delete(result) + return + + +class TodoManagerComponent: + is_cacheable = True + is_singleton = False + + def can_handle_parameter(self, parameter: Parameter) -> bool: + return parameter.annotation is TodoManager + + def resolve(self, session: Session, app: BaseApp) -> TodoManager: # type: ignore + return TodoManager(session, app) diff --git a/services/backend/pulley/api/todo/model.py b/services/backend/pulley/api/todo/model.py new file mode 100644 index 0000000..5886d18 --- /dev/null +++ b/services/backend/pulley/api/todo/model.py @@ -0,0 +1,21 @@ +from typing import Optional +from molten import schema, field +from sqlalchemy import Column, Text, Boolean +from pulley.db import Base, DBMixin +from pulley.schema import Link + + +@schema +class Todo: + id: int = field(response_only=True) + createdDate: str = field(response_only=True) + modifiedDate: str = field(response_only=True) + todo: str + complete: Optional[bool] + href: Link = field(response_only=True) + + +class TodoModel(Base, DBMixin): + __tablename__ = "todo" + todo = Column(Text) + complete = Column(Boolean, default=False) diff --git a/services/backend/pulley/api/todo/view.py b/services/backend/pulley/api/todo/view.py new file mode 100644 index 0000000..c078657 --- /dev/null +++ b/services/backend/pulley/api/todo/view.py @@ -0,0 +1,49 @@ +from typing import List +from molten import Route, Include, HTTP_201, HTTP_202, HTTPError, HTTP_404 + +from pulley.schema import APIResponse +from pulley.error import EntityNotFound +from .model import Todo +from .manager import TodoManager + + +def list_todos(todo_manager: TodoManager) -> List[Todo]: + return todo_manager.get_todos() + + +def create_todo(todo: Todo, todo_manager: TodoManager) -> Todo: + _todo = todo_manager.create_todo(todo) + headers = {"Location": _todo.href} + return HTTP_201, _todo, headers + + +def delete_todo(todo_id: int, todo_manager: TodoManager): + todo_manager.delete_todo(todo_id) + return ( + HTTP_202, + APIResponse(status=202, message=f"Delete request for todo: {todo_id} accepted"), + ) + + +def get_todo_by_id(todo_id: int, todo_manager: TodoManager) -> Todo: + try: + _todo = todo_manager.get_todo_by_id(todo_id) + except EntityNotFound as err: + raise HTTPError(HTTP_404, + APIResponse(status=404, + message=err.message) + ) + return _todo + + +def update_todo(todo_id: int, todo: Todo, todo_manager: TodoManager) -> Todo: + return todo_manager.update_todo(todo_id, todo) + + +todo_routes = Include("/todos", [ + Route("", list_todos, method="GET"), + Route("", create_todo, method="POST"), + Route("/{todo_id}", delete_todo, method="DELETE"), + Route("/{todo_id}", get_todo_by_id, method="GET"), + Route("/{todo_id}", update_todo, method="PATCH") +]) diff --git a/services/backend/pulley/api/welcome/__init__.py b/services/backend/pulley/api/welcome/__init__.py new file mode 100644 index 0000000..813c05e --- /dev/null +++ b/services/backend/pulley/api/welcome/__init__.py @@ -0,0 +1 @@ +from .views import welcome \ No newline at end of file diff --git a/services/backend/pulley/api/welcome/views.py b/services/backend/pulley/api/welcome/views.py new file mode 100644 index 0000000..3d47a04 --- /dev/null +++ b/services/backend/pulley/api/welcome/views.py @@ -0,0 +1,5 @@ +from typing import Dict + + +def welcome() -> Dict: + return {"message": "welcome to pulley"} diff --git a/services/backend/pulley/common.py b/services/backend/pulley/common.py new file mode 100644 index 0000000..a8f9c7d --- /dev/null +++ b/services/backend/pulley/common.py @@ -0,0 +1,31 @@ +import datetime as dt +from os import path +from decimal import Decimal +from typing import Any +from molten import JSONRenderer, is_schema, dump_schema + +BASE_PATH = path.normpath(path.join(path.abspath(path.dirname(__file__)), ".")) + + +def path_to(*xs): + """ + Construct a path from the root project directory + """ + return path.join(BASE_PATH, *xs) + + +class ExtJSONRenderer(JSONRenderer): + """JSON Render with support for ISO 8601 datetime format strings and Decimal""" + + def default(self, ob: Any) -> Any: + """You may override this when subclassing the JSON renderer in + order to encode non-standard object types. + """ + if is_schema(type(ob)): + return dump_schema(ob) + if isinstance(ob, dt.datetime): + return ob.isoformat() + if isinstance(ob, Decimal): + return float(ob) + + raise TypeError(f"cannot encode values of type {type(ob)}") # pragma: no cover diff --git a/services/backend/pulley/db.py b/services/backend/pulley/db.py new file mode 100644 index 0000000..7cff90f --- /dev/null +++ b/services/backend/pulley/db.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, BigInteger, DateTime +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.schema import CreateColumn +from sqlalchemy.sql import expression +from sqlalchemy.types import DateTime as DatetimeType + +Base = declarative_base() + + +class utcnow(expression.FunctionElement): + type = DatetimeType() + + +@compiles(utcnow, "postgresql") +def pg_utcnow(element, compiler, **kw): + return "TIMEZONE('utc', CURRENT_TIMESTAMP)" + + +@compiles(CreateColumn, 'postgresql') +def use_identity(element, compiler, **kw): + text = compiler.visit_create_column(element, **kw) + text = text.replace("SERIAL", "INT GENERATED BY DEFAULT AS IDENTITY") + return text + + +class DBMixin: + id = Column(BigInteger, primary_key=True) + created_date = Column(DateTime(timezone=True), server_default=utcnow()) + modified_date = Column( + DateTime(timezone=True), server_default=utcnow(), onupdate=utcnow() + ) diff --git a/services/backend/pulley/error.py b/services/backend/pulley/error.py new file mode 100644 index 0000000..2c29f4a --- /dev/null +++ b/services/backend/pulley/error.py @@ -0,0 +1,5 @@ +from molten.errors import MoltenError + + +class EntityNotFound(MoltenError): + """Raised when an entity is not found using an `exists` check in sqlalchemy.""" diff --git a/services/backend/pulley/index.py b/services/backend/pulley/index.py new file mode 100644 index 0000000..72730a5 --- /dev/null +++ b/services/backend/pulley/index.py @@ -0,0 +1,82 @@ +from typing import Tuple +from molten import App, Route, ResponseRendererMiddleware, Settings +from molten.http import HTTP_404, Request +from molten.openapi import Metadata, OpenAPIHandler, OpenAPIUIHandler +from molten.settings import SettingsComponent +from molten.contrib.sqlalchemy import SQLAlchemyMiddleware, SQLAlchemyEngineComponent, SQLAlchemySessionComponent +from wsgicors import CORS +from whitenoise import WhiteNoise + +from .api.welcome import welcome +# from .api.todo import TodoManagerComponent, todo_routes +from .common import ExtJSONRenderer +from .logger import setup_logging +from .schema import APIResponse +from . import settings + +get_schema = OpenAPIHandler( + metadata=Metadata( + title="pulley", + description="Globalmantics restful api", + version="0.0.0" + ) +) + +get_docs = OpenAPIUIHandler() + +components = [ + SettingsComponent(settings), + SQLAlchemyEngineComponent(), + SQLAlchemySessionComponent(), + # TodoManagerComponent(), +] + +middleware = [ResponseRendererMiddleware(), SQLAlchemyMiddleware()] + +renderers = [ExtJSONRenderer()] + +routes = [ + Route("/", welcome, "GET"), + Route("/_schema", get_schema, "GET"), + Route("/_docs", get_docs, "GET"), + ] # + [todo_routes] + + +class ExtApp(App): + def handle_404(self, request: Request) -> Tuple[str, APIResponse]: + """ + Returns as standardized JSONResponse on HTTP 404 Error. + """ + return ( + HTTP_404, + APIResponse( + status=404, + message=f"The resource you are looking for {request.scheme}://{request.host}{request.path} doesn't exist", + ), + ) + + @property + def settings(self): + def _get_settings(_settings: Settings): + return _settings + + settings = self.injector.get_resolver().resolve(_get_settings)() + return settings + + +def create_app(_components=None, _middleware=None, _routes=None, _renderers=None): + """ + Factory function for the creation of a `molten.App`. + """ + setup_logging() + + wrapped_app = app = ExtApp( + components=_components or components, + middleware=_middleware or middleware, + routes=_routes or routes, + renderers=_renderers or renderers + ) + wrapped_app = CORS(wrapped_app, **settings.strict_get("wsgicors")) + wrapped_app = WhiteNoise(wrapped_app, **settings.strict_get("whitenoise")) + + return wrapped_app, app diff --git a/services/backend/pulley/logger.py b/services/backend/pulley/logger.py new file mode 100644 index 0000000..fb36de8 --- /dev/null +++ b/services/backend/pulley/logger.py @@ -0,0 +1,29 @@ +import logging.config +import sys + +FORMAT = "[%(asctime)s] [PID %(process)d] [%(threadName)s] [%(request_id)s] [%(name)s] [%(levelname)s] %(message)s" # noqa + + +def setup_logging(): + logging.config.dictConfig( + { + "disable_existing_loggers": False, + "version": 1, + "filters": { + "request_id": {"()": "molten.contrib.request_id.RequestIdFilter"} + }, + "formatters": {"console": {"format": FORMAT}}, + "handlers": { + "default": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "stream": sys.stderr, + "formatter": "console", + "filters": ["request_id"], + } + }, + "loggers": { + "": {"handlers": ["default"], "level": "DEBUG", "propagate": False} + }, + } + ) diff --git a/services/backend/pulley/manager.py b/services/backend/pulley/manager.py new file mode 100644 index 0000000..96ac713 --- /dev/null +++ b/services/backend/pulley/manager.py @@ -0,0 +1,32 @@ +from abc import ABCMeta, abstractmethod +from molten import BaseApp, HTTPError, HTTP_409, HTTP_404 +from sqlalchemy.orm import Session + + +class BaseManager(metaclass=ABCMeta): + """Base instance for Model managers""" + + def __init__(self, session: Session, app: BaseApp): + self.session = session + self.app = app + + @abstractmethod + def model_from_schema(self, schema): + """Converts a Schema instance into a SQLAlchemy ORM model instance""" + pass + + @abstractmethod + def schema_from_model(self, result): + """Converts a SQLAlchemy results proxy into a Schema instance""" + pass + + def raise_409(self, id:int): + """Raises a 409 HTTP error response in the event of Conflict""" + raise HTTPError( + HTTP_409, + { + "status": 409, + "message": f"Entity {self.__class__.__name__} with id: {id} already exists" + } + + ) diff --git a/services/backend/pulley/schema.py b/services/backend/pulley/schema.py new file mode 100644 index 0000000..cd351da --- /dev/null +++ b/services/backend/pulley/schema.py @@ -0,0 +1,14 @@ +from molten import schema, field + + +@schema +class Link: + href: str + + +@schema +class APIResponse: + status: int = field(description="An HTTP status code") + message: str = field( + description="A user presentable message in response to the request provided to the API" + ) diff --git a/services/backend/pulley/settings.py b/services/backend/pulley/settings.py new file mode 100644 index 0000000..aa0e356 --- /dev/null +++ b/services/backend/pulley/settings.py @@ -0,0 +1,12 @@ +import os + +from molten.contrib.toml_settings import TOMLSettings + +from .common import path_to + +ENVIRONMENT = os.getenv("ENVIRONMENT", "dev") +SETTINGS = TOMLSettings.from_path(path_to("settings.toml"), ENVIRONMENT) + + +def __getattr__(name): + return getattr(SETTINGS, name) diff --git a/services/backend/pulley/settings.toml b/services/backend/pulley/settings.toml new file mode 100644 index 0000000..01723ff --- /dev/null +++ b/services/backend/pulley/settings.toml @@ -0,0 +1,26 @@ +[common] +database_engine_dsn = "postgresql://molten:local@localhost/cookiecutter" + +[common.wsgicors] +headers="*" +methods="*" +maxage="180" +origin="*" + +[common.whitenoise] +root = "static" +prefix = "/static" +autorefresh = true + +[dev] +database_engine_params.echo = true +database_engine_params.connect_args.options = "-c timezone=utc" + +[test] +database_engine_dsn = "postgresql://molten:local@localhost/test_cookiecutter" +database_engine_params.echo = true + +[prod.whitenoise] +root = "static" +prefix = "/static" +autorefresh = false \ No newline at end of file diff --git a/services/backend/requirements.in b/services/backend/requirements.in new file mode 100644 index 0000000..285aa97 --- /dev/null +++ b/services/backend/requirements.in @@ -0,0 +1,7 @@ +click +molten +sqlalchemy +psycopg2-binary +alembic +wsgicors +whitenoise \ No newline at end of file diff --git a/services/backend/requirements.txt b/services/backend/requirements.txt new file mode 100644 index 0000000..6904755 --- /dev/null +++ b/services/backend/requirements.txt @@ -0,0 +1,23 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements.in +# +--trusted-host pypi.python.org + +alembic==1.0.10 +click==7.0 +mako==1.0.10 # via alembic +markupsafe==1.1.1 # via mako +molten==0.7.4 +mypy-extensions==0.4.1 # via typing-inspect +psycopg2-binary==2.8.2 +python-dateutil==2.8.0 # via alembic +python-editor==1.0.4 # via alembic +six==1.12.0 # via python-dateutil +sqlalchemy==1.3.3 +typing-extensions==3.7.2 # via molten +typing-inspect==0.3.1 # via molten +whitenoise==4.1.2 +wsgicors==0.7.0 diff --git a/services/backend/scripts/bootstrap b/services/backend/scripts/bootstrap new file mode 100755 index 0000000..38e8a67 --- /dev/null +++ b/services/backend/scripts/bootstrap @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# setting -e to exit immediately on a command failure. +# setting -o pipefail sets the exit code of a pipeline to that of the rightmost command to exit with a non-zero status, or to zero if all commands of the pipeline exit successfully. +set -eo pipefail + +if [ -z "$VIRTUAL_ENV" ]; then + echo "warning: you are not in a virtualenv" + exit 1 +fi + +pip install -U pip pip-tools +pip-compile requirements.in +pip-compile dev_requirements.in +pip-sync requirements.txt dev_requirements.txt diff --git a/services/backend/setup.py b/services/backend/setup.py new file mode 100644 index 0000000..11e0238 --- /dev/null +++ b/services/backend/setup.py @@ -0,0 +1,15 @@ +from setuptools import setup, find_packages + +setup( + name="pulley", + version="0.1.0", + author="Drew Bednar", + author_email="drew@androiddrew.com", + description="Globalmantics restful api", + packages=find_packages(exclude=["tests"]), + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT license", + "Operating System :: OS Independent", + ], +) diff --git a/services/backend/tests/__init__.py b/services/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/tests/conftest.py b/services/backend/tests/conftest.py new file mode 100644 index 0000000..9ee602c --- /dev/null +++ b/services/backend/tests/conftest.py @@ -0,0 +1,51 @@ +import pytest + +from molten import testing +from molten.contrib.sqlalchemy import Session + +from pulley.index import create_app + + +def truncate_all_tables(session: Session): + table_names = session.execute(""" + select table_name from information_schema.tables + where table_schema = 'public' + and table_type = 'BASE TABLE' + and table_name != 'alembic_version' + """) + for (table_name,) in table_names: + # "truncate" can deadlock so we use delete which is guaranteed not to. + session.execute(f"delete from {table_name}") + session.commit() + + +@pytest.fixture(scope="session") +def app_global(): + _, app = create_app() + yield app + + +@pytest.fixture +def app(app_global): + # This is a little "clever"/piggy. We only want a single instance + # of the app to ever be created, but we also want to ensure that + # the DB is cleared after every test hence "app_global" being a + # session-scoped fixture and this one being test-scoped. + yield app_global + resolver = app_global.injector.get_resolver() + resolver.resolve(truncate_all_tables)() + + +@pytest.fixture +def client(app): + """Creates a testing client""" + return testing.TestClient(app) + + +@pytest.fixture +def load_component(app): + def load(annotation): + def loader(c: annotation): + return c + return app.injector.get_resolver().resolve(loader)() + return load diff --git a/services/backend/tests/test_app_routes.py b/services/backend/tests/test_app_routes.py new file mode 100644 index 0000000..d0d41fe --- /dev/null +++ b/services/backend/tests/test_app_routes.py @@ -0,0 +1,55 @@ +def test_welcome_route(client): + message = "welcome to pulley" + response = client.get("/") + content = response.json() + assert message == content.get("message") +# +# +# def test_empty_get_todos(client): +# response = client.get("/todos") +# assert response.status_code == 200 +# assert response.json() == [] +# +# +# def test_insert_todo(client): +# payload = {"todo": "walk the dog"} +# response = client.post("/todos", data=payload) +# content = response.json() +# assert response.status_code == 201 +# assert type(content['id']) == int +# assert content['todo'] == payload['todo'] +# +# +# def test_get_individual_todo_by_href(client): +# payload = {"todo": "my individual todo"} +# response = client.post("/todos", data=payload) +# content = response.json() +# get_response = client.get(f"{content.get('href')}") +# get_content = get_response.json() +# assert get_response.status_code == 200 +# assert content == get_content +# +# +# def test_update_todo(client): +# payload = {"todo": "sample app"} +# response = client.post("/todos", json=payload) +# todo = response.json() +# update_response = client.patch("{}".format(todo.get("href")), json={"complete": True, "todo": "sample app"}) +# updated_todo = update_response.json() +# assert updated_todo["complete"] == True +# +# +# def test_todo_not_found(client): +# response = client.get("/todo/1111111") +# content = response.json() +# assert response.status_code == 404 +# assert content["status"] == 404 +# assert content["message"] +# +# +# def test_delete_todo(client): +# payload = {"todo": "sample app"} +# response = client.post("/todos", json=payload) +# todo = response.json() +# delete_response = client.delete(f"/todos/{todo.get('id')}") +# assert delete_response.status_code == 202 diff --git a/services/backend/tests/test_renders.py b/services/backend/tests/test_renders.py new file mode 100644 index 0000000..acd3ace --- /dev/null +++ b/services/backend/tests/test_renders.py @@ -0,0 +1,15 @@ +import datetime as dt +from decimal import Decimal +from pulley.common import ExtJSONRenderer + + +def test_extended_encoder_date_parsing(): + json_renderer = ExtJSONRenderer() + test_date = dt.datetime(2017, 5, 10) + assert test_date.isoformat() == json_renderer.default(test_date) + + +def test_extended_encoder_decimal_casting(): + json_renderer = ExtJSONRenderer() + test_decimal = Decimal('1.0') + assert 1.0 == json_renderer.default(test_decimal)