From 262d32fea46bd9742f32f7f275cf9d70077cd28f Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sat, 22 Feb 2020 08:15:20 -0500 Subject: [PATCH] Saving my work --- casbin_api/api/auth/__init__.py | 0 casbin_api/api/auth/manager.py | 76 +++++++++++++++++++ casbin_api/api/auth/model.py | 41 ++++++++++ casbin_api/api/auth/view.py | 41 ++++++++++ casbin_api/api/todo/__init__.py | 2 +- casbin_api/api/todo/manager.py | 7 +- casbin_api/api/todo/view.py | 22 +++--- casbin_api/api/welcome/__init__.py | 2 +- casbin_api/db.py | 2 +- casbin_api/index.py | 30 +++++--- casbin_api/manager.py | 7 +- manage.py | 4 +- migrations/env.py | 6 +- .../697808db5f01_initial_migration.py | 29 ++++--- requirements.in | 1 + requirements.txt | 5 +- scripts/{bootstrap => update_env.sh} | 0 tests/conftest.py | 8 +- tests/test_app_routes.py | 8 +- tests/test_auth_routes.py | 57 ++++++++++++++ tests/test_renders.py | 2 +- 21 files changed, 298 insertions(+), 52 deletions(-) create mode 100644 casbin_api/api/auth/__init__.py create mode 100644 casbin_api/api/auth/manager.py create mode 100644 casbin_api/api/auth/model.py create mode 100644 casbin_api/api/auth/view.py rename scripts/{bootstrap => update_env.sh} (100%) create mode 100644 tests/test_auth_routes.py diff --git a/casbin_api/api/auth/__init__.py b/casbin_api/api/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/casbin_api/api/auth/manager.py b/casbin_api/api/auth/manager.py new file mode 100644 index 0000000..57651cb --- /dev/null +++ b/casbin_api/api/auth/manager.py @@ -0,0 +1,76 @@ +from inspect import Parameter +from typing import List +from molten import BaseApp, HTTPError, HTTP_409, HTTP_404 +from sqlalchemy.orm import Session + +from casbin_api.manager import BaseManager +from casbin_api.error import EntityNotFound +from .model import User, UserModel + + +class UserManager(BaseManager): + """A `TodoManager` is accountable for the CRUD operations associated with a `Todo` instance""" + + def schema_from_model(self, result: UserModel) -> User: + _user = User( + id=result.id, + href=self.app.reverse_uri("get_user_by_id", user_id=result.id), + createdDate=result.created_date, + modifiedDate=result.modified_date, + todo=result.todo, + ) + return _user + + def model_from_schema(self, user: User) -> UserModel: + _user_model = UserModel(user.email, user.passwd) + return _user_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/casbin_api/api/auth/model.py b/casbin_api/api/auth/model.py new file mode 100644 index 0000000..cdb8553 --- /dev/null +++ b/casbin_api/api/auth/model.py @@ -0,0 +1,41 @@ +import bcrypt +from typing import Optional +from molten import schema, field +from sqlalchemy import relationship, Column, Text, String, Boolean +from casbin_api.db import Base, DBMixin +from casbin_api.schema import Link + +BCRYPT_ROUNDS = 11 + + +@schema +class AuthToken: + token: str = field(response_only=True) + + +@schema +class User: + id: int = field(response_only=True) + createdDate: str = field(response_only=True) + modifiedDate: str = field(response_only=True) + email: str + passwd: str = field(request_only=True) + href: Link = field(response_only=True) + + +class UserModel(Base, DBMixin): + __tablename__ = "user_account" + email = Column(Text) + passwd = Column(String(255)) + todos = relationship("TodoModel", back_populates="user") + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + + def __init__(self, email, passwd, is_admin=False): + self.email = email + self.passwd = bcrypt.hashpw(passwd.encode('utf-8', bcrypt.gensalt(BCRYPT_ROUNDS))).decode('utf-8') + self.is_admin = is_admin + + def check_password(self, passwd): + """Checks provided password against saved password hash.""" + return bcrypt.checkpw(passwd.encode('utf-8'), self.passwd.encode('utf-8')) diff --git a/casbin_api/api/auth/view.py b/casbin_api/api/auth/view.py new file mode 100644 index 0000000..b5dfa5b --- /dev/null +++ b/casbin_api/api/auth/view.py @@ -0,0 +1,41 @@ +from typing import List +from molten import Route, Include, HTTP_201, HTTP_202, HTTPError, HTTP_404 + +from casbin_api.schema import APIResponse +from casbin_api.error import EntityNotFound +from .model import User, AuthToken +from .manager import UserManager + + +def create_user(user: User, user_manager: UserManager) -> User: + _user = user_manager.create_user(user) + headers = {"Location": _user.href} + return HTTP_201, _user, headers + + +def get_user_by_id(user_id: int, user_manager: UserManager) -> User: + try: + _user = user_manager.get_user_by_id(user_id) + except EntityNotFound as err: + raise HTTPError(HTTP_404, APIResponse(status=404, message=err.message)) + return _user + + +# Update user only if you are the user or you are admin +def update_user(user_id: int, user: User, user_manager: UserManager) -> User: + return user_manager.update_todo(user_id, user) + + +def login_user(user: User, user_manager: UserManager) -> AuthToken: + pass + + +auth_routes = Include( + "/auth", + [ + Route("/login", login_user, method="POST"), + Route("/register", create_user, method="POST"), + Route("/profile/{user_id}", get_user_by_id, method="GET"), + Route("/profile/{user_id}", update_user, method="PUT"), + ], +) diff --git a/casbin_api/api/todo/__init__.py b/casbin_api/api/todo/__init__.py index caaebba..0b9dea1 100644 --- a/casbin_api/api/todo/__init__.py +++ b/casbin_api/api/todo/__init__.py @@ -1,2 +1,2 @@ from .manager import TodoManager, TodoManagerComponent -from .view import todo_routes \ No newline at end of file +from .view import todo_routes diff --git a/casbin_api/api/todo/manager.py b/casbin_api/api/todo/manager.py index d90cce1..898ea62 100644 --- a/casbin_api/api/todo/manager.py +++ b/casbin_api/api/todo/manager.py @@ -18,15 +18,12 @@ class TodoManager(BaseManager): createdDate=result.created_date, modifiedDate=result.modified_date, todo=result.todo, - complete=result.complete + complete=result.complete, ) return _todo def model_from_schema(self, todo: Todo) -> TodoModel: - _todo_model = TodoModel( - todo=todo.todo, - complete=todo.complete - ) + _todo_model = TodoModel(todo=todo.todo, complete=todo.complete) return _todo_model def get_todos(self) -> List[Todo]: diff --git a/casbin_api/api/todo/view.py b/casbin_api/api/todo/view.py index 5704ae9..87669ad 100644 --- a/casbin_api/api/todo/view.py +++ b/casbin_api/api/todo/view.py @@ -29,10 +29,7 @@ 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) - ) + raise HTTPError(HTTP_404, APIResponse(status=404, message=err.message)) return _todo @@ -40,10 +37,13 @@ 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") -]) +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/casbin_api/api/welcome/__init__.py b/casbin_api/api/welcome/__init__.py index 813c05e..7d762b3 100644 --- a/casbin_api/api/welcome/__init__.py +++ b/casbin_api/api/welcome/__init__.py @@ -1 +1 @@ -from .views import welcome \ No newline at end of file +from .views import welcome diff --git a/casbin_api/db.py b/casbin_api/db.py index 7cff90f..bdc4e2e 100644 --- a/casbin_api/db.py +++ b/casbin_api/db.py @@ -17,7 +17,7 @@ def pg_utcnow(element, compiler, **kw): return "TIMEZONE('utc', CURRENT_TIMESTAMP)" -@compiles(CreateColumn, 'postgresql') +@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") diff --git a/casbin_api/index.py b/casbin_api/index.py index 8e016a8..7f915e2 100644 --- a/casbin_api/index.py +++ b/casbin_api/index.py @@ -3,9 +3,20 @@ 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 molten.contrib.sqlalchemy import ( + SQLAlchemyMiddleware, + SQLAlchemyEngineComponent, + SQLAlchemySessionComponent, +) from wsgicors import CORS from whitenoise import WhiteNoise +from molten_jwt import ( + JWT, + JWTIdentity, + JWTComponent, + JWTIdentityComponent, + JWTAuthMiddleware, +) from .api.welcome import welcome from .api.todo import TodoManagerComponent, todo_routes @@ -18,28 +29,29 @@ get_schema = OpenAPIHandler( metadata=Metadata( title="casbin_api", description="A Molten web api with RBAC provided by pycasbin", - version="0.0.0" + version="0.0.0", ) ) get_docs = OpenAPIUIHandler() components = [ + JWTIdentityComponent(), SettingsComponent(settings), SQLAlchemyEngineComponent(), SQLAlchemySessionComponent(), TodoManagerComponent(), ] -middleware = [ResponseRendererMiddleware(), SQLAlchemyMiddleware()] +middleware = [ResponseRendererMiddleware(), SQLAlchemyMiddleware(), JWTAuthMiddleware] renderers = [ExtJSONRenderer()] routes = [ - Route("/", welcome, "GET"), - Route("/_schema", get_schema, "GET"), - Route("/_docs", get_docs, "GET"), - ] + [todo_routes] + Route("/", welcome, "GET"), + Route("/_schema", get_schema, "GET"), + Route("/_docs", get_docs, "GET"), +] + [todo_routes] class ExtApp(App): @@ -74,9 +86,9 @@ def create_app(_components=None, _middleware=None, _routes=None, _renderers=None components=_components or components, middleware=_middleware or middleware, routes=_routes or routes, - renderers=_renderers or renderers + renderers=_renderers or renderers, ) - wrapped_app = CORS(wrapped_app, **settings.strict_get("wsgicors")) + 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/casbin_api/manager.py b/casbin_api/manager.py index 96ac713..91ac96f 100644 --- a/casbin_api/manager.py +++ b/casbin_api/manager.py @@ -20,13 +20,12 @@ class BaseManager(metaclass=ABCMeta): """Converts a SQLAlchemy results proxy into a Schema instance""" pass - def raise_409(self, id:int): + 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" - } - + "message": f"Entity {self.__class__.__name__} with id: {id} already exists", + }, ) diff --git a/manage.py b/manage.py index 04fa950..987fba7 100644 --- a/manage.py +++ b/manage.py @@ -47,7 +47,9 @@ def initdb(): """ Initialize database """ - click.echo("This feature has been commented out. Please use alembic to manage your database initialization and changes.") + click.echo( + "This feature has been commented out. Please use alembic to manage your database initialization and changes." + ) # from casbin_api.db import Base # # def _init(engine_data: EngineData): diff --git a/migrations/env.py b/migrations/env.py index b851aaf..b1a2c1a 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,7 +1,9 @@ """isort:skip_file """ import os -import sys; sys.path.append(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..")) # noqa +import sys + +sys.path.append(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..")) # noqa from alembic import context from casbin_api.index import create_app @@ -18,4 +20,4 @@ def run_migrations_online(engine_data: EngineData): context.run_migrations() -app.injector.get_resolver().resolve(run_migrations_online)() \ No newline at end of file +app.injector.get_resolver().resolve(run_migrations_online)() diff --git a/migrations/versions/697808db5f01_initial_migration.py b/migrations/versions/697808db5f01_initial_migration.py index 418e84e..2e976e0 100644 --- a/migrations/versions/697808db5f01_initial_migration.py +++ b/migrations/versions/697808db5f01_initial_migration.py @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '697808db5f01' +revision = "697808db5f01" down_revision = None branch_labels = None depends_on = None @@ -18,18 +18,29 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('todo', - sa.Column('id', sa.BigInteger(), nullable=False), - sa.Column('created_date', sa.DateTime(timezone=True), server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), nullable=True), - sa.Column('modified_date', sa.DateTime(timezone=True), server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), nullable=True), - sa.Column('todo', sa.Text(), nullable=True), - sa.Column('complete', sa.Boolean(), nullable=True), - sa.PrimaryKeyConstraint('id') + op.create_table( + "todo", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column( + "created_date", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + nullable=True, + ), + sa.Column( + "modified_date", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + nullable=True, + ), + sa.Column("todo", sa.Text(), nullable=True), + sa.Column("complete", sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('todo') + op.drop_table("todo") # ### end Alembic commands ### diff --git a/requirements.in b/requirements.in index 8a5a4cf..8c28ac0 100644 --- a/requirements.in +++ b/requirements.in @@ -1,3 +1,4 @@ +bcrypt click molten sqlalchemy diff --git a/requirements.txt b/requirements.txt index 46562bf..015c7d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,8 @@ # alembic==1.4.0 authlib==0.12.1 # via molten-jwt -cffi==1.14.0 # via cryptography +bcrypt==3.1.7 +cffi==1.14.0 # via bcrypt, cryptography click==7.0 cryptography==2.8 # via authlib mako==1.1.1 # via alembic @@ -18,7 +19,7 @@ psycopg2-binary==2.8.4 pycparser==2.19 # via cffi python-dateutil==2.8.1 # via alembic python-editor==1.0.4 # via alembic -six==1.14.0 # via cryptography, python-dateutil +six==1.14.0 # via bcrypt, cryptography, python-dateutil sqlalchemy==1.3.13 typing-extensions==3.7.4.1 # via molten, typing-inspect typing-inspect==0.5.0 # via molten diff --git a/scripts/bootstrap b/scripts/update_env.sh similarity index 100% rename from scripts/bootstrap rename to scripts/update_env.sh diff --git a/tests/conftest.py b/tests/conftest.py index 137443b..0cc9f3e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,12 +7,14 @@ from casbin_api.index import create_app def truncate_all_tables(session: Session): - table_names = session.execute(""" + 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}") @@ -47,5 +49,7 @@ 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/tests/test_app_routes.py b/tests/test_app_routes.py index efb67c6..4049c74 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -16,8 +16,8 @@ def test_insert_todo(client): response = client.post("/todos", data=payload) content = response.json() assert response.status_code == 201 - assert type(content['id']) == int - assert content['todo'] == payload['todo'] + assert type(content["id"]) == int + assert content["todo"] == payload["todo"] def test_get_individual_todo_by_href(client): @@ -34,7 +34,9 @@ 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"}) + update_response = client.patch( + "{}".format(todo.get("href")), json={"complete": True, "todo": "sample app"} + ) updated_todo = update_response.json() assert updated_todo["complete"] == True diff --git a/tests/test_auth_routes.py b/tests/test_auth_routes.py new file mode 100644 index 0000000..459ee4a --- /dev/null +++ b/tests/test_auth_routes.py @@ -0,0 +1,57 @@ +def test_token_route(client): + message = "welcome to casbin_api" + 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/tests/test_renders.py b/tests/test_renders.py index 2a5305d..0a9aabc 100644 --- a/tests/test_renders.py +++ b/tests/test_renders.py @@ -11,5 +11,5 @@ def test_extended_encoder_date_parsing(): def test_extended_encoder_decimal_casting(): json_renderer = ExtJSONRenderer() - test_decimal = Decimal('1.0') + test_decimal = Decimal("1.0") assert 1.0 == json_renderer.default(test_decimal)