From 31df889dcb101fade4287c6a432027a606594fa9 Mon Sep 17 00:00:00 2001 From: binaryDiv Date: Sat, 2 Apr 2022 01:06:59 +0200 Subject: [PATCH] Add SQLAlchemy and database boilerplate code --- Pipfile | 1 + Pipfile.lock | 116 +++++++++++++++++++++++-- tofu_api/api/rest_api.py | 13 +-- tofu_api/api/tasks/task_api.py | 76 ++++++++++------ tofu_api/app.py | 68 +++++++++++---- tofu_api/common/__init__.py | 0 tofu_api/common/database/__init__.py | 3 + tofu_api/common/database/metadata.py | 17 ++++ tofu_api/common/database/model.py | 10 +++ tofu_api/common/database/sqlalchemy.py | 87 +++++++++++++++++++ tofu_api/common/rest/__init__.py | 1 + tofu_api/common/rest/base_blueprint.py | 72 +++++++++++++++ tofu_api/dependencies.py | 24 +++++ tofu_api/models/__init__.py | 1 + tofu_api/models/task.py | 25 ++++++ 15 files changed, 454 insertions(+), 60 deletions(-) create mode 100644 tofu_api/common/__init__.py create mode 100644 tofu_api/common/database/__init__.py create mode 100644 tofu_api/common/database/metadata.py create mode 100644 tofu_api/common/database/model.py create mode 100644 tofu_api/common/database/sqlalchemy.py create mode 100644 tofu_api/common/rest/__init__.py create mode 100644 tofu_api/common/rest/base_blueprint.py create mode 100644 tofu_api/dependencies.py create mode 100644 tofu_api/models/__init__.py create mode 100644 tofu_api/models/task.py diff --git a/Pipfile b/Pipfile index cfbb521..68a0036 100644 --- a/Pipfile +++ b/Pipfile @@ -8,6 +8,7 @@ gunicorn = "~=20.1" werkzeug = "~=2.0" flask = "~=2.0" pyyaml = "*" +sqlalchemy = "~=1.4" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index c007e70..5470355 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "57e212392ff70382ed9ae0637e05f3696fa1d815f226e564910d296f7e2b5027" + "sha256": "c58e87fa3968e9700fc194b98a663138ce212ee9601afcfd1e4478a772041294" }, "pipfile-spec": 6, "requires": { @@ -32,6 +32,67 @@ "index": "pypi", "version": "==2.0.3" }, + "greenlet": { + "hashes": [ + "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3", + "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711", + "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd", + "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073", + "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708", + "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67", + "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23", + "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1", + "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08", + "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd", + "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2", + "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa", + "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8", + "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40", + "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab", + "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6", + "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc", + "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b", + "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e", + "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963", + "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3", + "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d", + "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d", + "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe", + "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28", + "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3", + "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e", + "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c", + "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d", + "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0", + "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497", + "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee", + "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713", + "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58", + "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a", + "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06", + "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88", + "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965", + "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f", + "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4", + "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5", + "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c", + "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a", + "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1", + "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43", + "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627", + "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b", + "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168", + "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d", + "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5", + "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478", + "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf", + "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce", + "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c", + "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b" + ], + "markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", + "version": "==1.1.2" + }, "gunicorn": { "hashes": [ "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", @@ -50,11 +111,11 @@ }, "jinja2": { "hashes": [ - "sha256:a2f09a92f358b96b5f6ca6ecb4502669c4acb55d8733bbb2b2c9c4af5564c605", - "sha256:da424924c069a4013730d8dd010cbecac7e7bb752be388db3741688bffb48dc6" + "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119", + "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9" ], "markers": "python_version >= '3.7'", - "version": "==3.1.0" + "version": "==3.1.1" }, "markupsafe": { "hashes": [ @@ -143,11 +204,52 @@ }, "setuptools": { "hashes": [ - "sha256:6221e37dc86fcdc9dad9d9eb2002e9f9798fe4aca1bf18f280e66e50c0eb7fca", - "sha256:ad88b13f3dc60420259c9877486908ddad12c7befaff0d624c7190f742abd64f" + "sha256:63b73d83c4d419d9dd992978cf369af5b6efa06236a4a1397b9bdf3ecadb0453", + "sha256:9bc2671450c57d9e4885e7dcdc552e3b107b80a5a8f8b1a1df7d06f52db194be" ], "markers": "python_version >= '3.7'", - "version": "==61.0.0" + "version": "==61.1.0" + }, + "sqlalchemy": { + "hashes": [ + "sha256:04164e0063feb7aedd9d073db0fd496edb244be40d46ea1f0d8990815e4b8c34", + "sha256:159c2f69dd6efd28e894f261ffca1100690f28210f34cfcd70b895e0ea7a64f3", + "sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674", + "sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5", + "sha256:20e9eba7fd86ef52e0df25bea83b8b518dfdf0bce09b336cfe51671f52aaaa3f", + "sha256:290cbdf19129ae520d4bdce392648c6fcdbee763bc8f750b53a5ab51880cb9c9", + "sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e", + "sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97", + "sha256:4aa96e957141006181ca58e792e900ee511085b8dae06c2d08c00f108280fb8a", + "sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16", + "sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00", + "sha256:5a2e73508f939175363d8a4be9dcdc84cf16a92578d7fa86e6e4ca0e6b3667b2", + "sha256:5ba59761c19b800bc2e1c9324da04d35ef51e4ee9621ff37534bc2290d258f71", + "sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089", + "sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc", + "sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e", + "sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b", + "sha256:8679f9aba5ac22e7bce54ccd8a77641d3aea3e2d96e73e4356c887ebf8ff1082", + "sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13", + "sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac", + "sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615", + "sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13", + "sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4", + "sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1", + "sha256:bca714d831e5b8860c3ab134c93aec63d1a4f493bed20084f54e3ce9f0a3bf99", + "sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558", + "sha256:bfec934aac7f9fa95fc82147a4ba5db0a8bdc4ebf1e33b585ab8860beb10232f", + "sha256:c7046f7aa2db445daccc8424f50b47a66c4039c9f058246b43796aa818f8b751", + "sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423", + "sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55", + "sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed", + "sha256:e9a680d9665f88346ed339888781f5236347933906c5a56348abb8261282ec48", + "sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76", + "sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9", + "sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd" + ], + "index": "pypi", + "version": "==1.4.32" }, "werkzeug": { "hashes": [ diff --git a/tofu_api/api/rest_api.py b/tofu_api/api/rest_api.py index e9abdaa..fea8814 100644 --- a/tofu_api/api/rest_api.py +++ b/tofu_api/api/rest_api.py @@ -1,14 +1,15 @@ -from flask import Blueprint - +from tofu_api.common.rest import BaseBlueprint from .tasks import TaskApiBlueprint -class TofuApiBlueprint(Blueprint): +class TofuApiBlueprint(BaseBlueprint): """ Main blueprint for the Tofu REST API. """ - def __init__(self): - super().__init__('rest_api', __name__, url_prefix='/api') + name = 'rest_api' + import_name = __name__ + url_prefix = '/api' - self.register_blueprint(TaskApiBlueprint()) + def init_blueprint(self) -> None: + self.register_blueprint(TaskApiBlueprint(self.app)) diff --git a/tofu_api/api/tasks/task_api.py b/tofu_api/api/tasks/task_api.py index 931c4aa..a4fff4a 100644 --- a/tofu_api/api/tasks/task_api.py +++ b/tofu_api/api/tasks/task_api.py @@ -1,28 +1,53 @@ -from flask import Blueprint, jsonify +from flask import jsonify from flask.views import MethodView +from sqlalchemy.orm import Session +from werkzeug.exceptions import NotFound + +from tofu_api.common.rest import BaseBlueprint +from tofu_api.models import Task -class TaskApiBlueprint(Blueprint): +class TaskApiBlueprint(BaseBlueprint): """ Blueprint for the tasks REST API. """ - def __init__(self): - super().__init__('rest_api_tasks', __name__, url_prefix='/tasks') + # Blueprint settings + name = 'rest_api_tasks' + import_name = __name__ + url_prefix = '/tasks' + + def init_blueprint(self) -> None: + """ + Register URL rules. + """ + db_session = self.app.dependencies.get_db_session() self.add_url_rule( - '/', - view_func=TaskCollectionView.as_view(TaskCollectionView.__name__), + '', + view_func=self.create_view_func(TaskCollectionView, db_session=db_session), methods=['GET', 'POST'], ) self.add_url_rule( '/', - view_func=TaskItemView.as_view(TaskItemView.__name__), + view_func=self.create_view_func(TaskItemView, db_session=db_session), methods=['GET', 'PATCH', 'DELETE'], ) -class TaskCollectionView(MethodView): +class TaskBaseView(MethodView): + """ + Base class for view classes for the `/tasks` endpoint. + """ + + # TODO: Use a handler class instead of accessing the database session directly + db_session: Session + + def __init__(self, *, db_session: Session): + self.db_session = db_session + + +class TaskCollectionView(TaskBaseView): """ View class for `/tasks` endpoint. """ @@ -31,28 +56,26 @@ class TaskCollectionView(MethodView): """ Get list of all tasks. """ - # TODO: Get actual data + task_list = self.db_session.query(Task).all() return jsonify({ - 'count': 1, - 'items': [ - { - 'id': 1, - 'title': 'Do stuff', - 'description': '', - 'status': 'open', - }, - ], + 'count': len(task_list), + 'items': [task.to_dict() for task in task_list], }), 200 def post(self): """ Create a new task. """ - # TODO: Implement - raise NotImplementedError + # TODO: Parse request data and create real data + new_task = Task( + title='Do stuff' + ) + self.db_session.add(new_task) + self.db_session.commit() + return jsonify(new_task.to_dict()), 201 -class TaskItemView(MethodView): +class TaskItemView(TaskBaseView): """ View class for `/tasks/` endpoint. """ @@ -61,13 +84,10 @@ class TaskItemView(MethodView): """ Get a single task by ID. """ - # TODO: Get actual data - return jsonify({ - 'id': task_id, - 'title': 'Do stuff', - 'description': '', - 'status': 'open', - }), 200 + task = self.db_session.query(Task).get(task_id) + if task is None: + raise NotFound(f'Task with ID {task_id} not found!') + return jsonify(task.to_dict()), 200 def patch(self, task_id: int): """ diff --git a/tofu_api/app.py b/tofu_api/app.py index b21d5de..f5a2038 100644 --- a/tofu_api/app.py +++ b/tofu_api/app.py @@ -5,28 +5,58 @@ from flask import Flask from tofu_api.api import TofuApiBlueprint from tofu_api.config import DefaultConfig +from tofu_api.dependencies import Dependencies + + +class App(Flask): + """ + Flask application for Tofu API. + """ + # Dependencies container + dependencies: Dependencies + + def __init__(self): + # Set instance path to the project root directory + project_root_dir = os.path.abspath('.') + + # Create and configure the app + super().__init__( + 'tofu_api', + instance_path=project_root_dir, + instance_relative_config=True, + ) + self.config.from_object(DefaultConfig) + + # Load app configuration from YAML file + self.config.from_file(os.getenv('FLASK_CONFIG_FILE', default='config.yml'), load=yaml.safe_load) + + # Initialize DI container + self.dependencies = Dependencies() + + # Initialize dependencies + self.init_database() + + # Register blueprints + self.register_blueprint(TofuApiBlueprint(self)) + + def init_database(self) -> None: + """ + Initialize database connection and models. + """ + # Initialize SQLAlchemy, create the database engine based on the app config + db = self.dependencies.get_sqlalchemy() + db.init_database(self) + + # Import models to fill the metadata object + import tofu_api.models # noqa (unused import) + + # Create all tables + # TODO: Use migrations instead + db.create_all_tables() def create_app() -> Flask: """ App factory, returns a Flask app object. """ - # Set instance path to the project root directory - project_root_dir = os.path.abspath('.') - - # Create and configure the app - app = Flask( - 'tofu_api', - instance_path=project_root_dir, - instance_relative_config=True, - ) - app.config.from_object(DefaultConfig) - - # Load app configuration from YAML file - app.config.from_file(os.getenv('FLASK_CONFIG_FILE', default='config.yml'), load=yaml.safe_load) - - # Register blueprints - app.register_blueprint(TofuApiBlueprint()) - - # Return WSGI app - return app + return App() diff --git a/tofu_api/common/__init__.py b/tofu_api/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tofu_api/common/database/__init__.py b/tofu_api/common/database/__init__.py new file mode 100644 index 0000000..c46d890 --- /dev/null +++ b/tofu_api/common/database/__init__.py @@ -0,0 +1,3 @@ +from .metadata import metadata_obj +from .model import Model +from .sqlalchemy import SQLAlchemy diff --git a/tofu_api/common/database/metadata.py b/tofu_api/common/database/metadata.py new file mode 100644 index 0000000..3e5f2ad --- /dev/null +++ b/tofu_api/common/database/metadata.py @@ -0,0 +1,17 @@ +from sqlalchemy import MetaData + +__all__ = [ + 'metadata_obj', +] + +# Define naming convention for constraints +_naming_convention = { + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" +} + +# Create global metadata object for database schemas +metadata_obj = MetaData(naming_convention=_naming_convention) diff --git a/tofu_api/common/database/model.py b/tofu_api/common/database/model.py new file mode 100644 index 0000000..905bd25 --- /dev/null +++ b/tofu_api/common/database/model.py @@ -0,0 +1,10 @@ +from sqlalchemy.orm import declarative_base + +from .metadata import metadata_obj + +__all__ = [ + 'Model', +] + +# Generate declarative base class for database models +Model = declarative_base(name='Model', metadata=metadata_obj) diff --git a/tofu_api/common/database/sqlalchemy.py b/tofu_api/common/database/sqlalchemy.py new file mode 100644 index 0000000..f3ab214 --- /dev/null +++ b/tofu_api/common/database/sqlalchemy.py @@ -0,0 +1,87 @@ +from typing import Optional, cast + +from flask import Flask +from sqlalchemy import MetaData, create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, scoped_session, sessionmaker + +from .metadata import metadata_obj + +__all__ = [ + 'SQLAlchemy', +] + + +class SQLAlchemy: + """ + Wrapper class for integrating SQLAlchemy into the application as a Flask extension. + """ + _engine: Optional[Engine] = None + _scoped_session: Optional[scoped_session] = None + + def init_database(self, app: Flask): + """ + Initializes the SQLAlchemy engine. + """ + self._engine = self._create_engine() + self._scoped_session = self._create_scoped_session() + + @app.teardown_appcontext + def shutdown_session(_exception=None): + self._scoped_session.remove() + + def _create_engine(self) -> Engine: + """ + Create the database engine using the app configuration. + """ + # TODO: Use config + return create_engine('sqlite:////tmp/test.db') + + def _create_scoped_session(self) -> scoped_session: + """ + Create a scoped session. + """ + return scoped_session( + sessionmaker( + autocommit=False, + autoflush=False, + bind=self._engine, + ) + ) + + @property + def engine(self) -> Engine: + """ + Database engine. + """ + assert self._engine, 'Engine not ready yet.' + return self._engine + + @property + def session(self) -> Session: + """ + Scoped database session. + """ + assert self._scoped_session, 'Session not ready yet.' + # For all further purposes, the scoped session should be treated like a regular Session object. + # Use cast() so we can use Session as the type annotation. + return cast(Session, self._scoped_session) + + @property + def metadata(self) -> MetaData: + """ + Database metadata object. + """ + return metadata_obj + + def create_all_tables(self) -> None: + """ + Create tables in the database for all models defined in the metadata. + """ + self.metadata.create_all(self.engine) + + def drop_all_tables(self) -> None: + """ + Delete tables in the database for all models defined in the metadata. + """ + self.metadata.drop_all(self.engine) diff --git a/tofu_api/common/rest/__init__.py b/tofu_api/common/rest/__init__.py new file mode 100644 index 0000000..91d7bc8 --- /dev/null +++ b/tofu_api/common/rest/__init__.py @@ -0,0 +1 @@ +from .base_blueprint import BaseBlueprint diff --git a/tofu_api/common/rest/base_blueprint.py b/tofu_api/common/rest/base_blueprint.py new file mode 100644 index 0000000..9d46569 --- /dev/null +++ b/tofu_api/common/rest/base_blueprint.py @@ -0,0 +1,72 @@ +from abc import ABC, abstractmethod +from typing import Callable, Type, TYPE_CHECKING + +from flask import Blueprint +from flask.views import View + +if TYPE_CHECKING: + from tofu_api.app import App + +__all__ = [ + 'BaseBlueprint', +] + + +class BaseBlueprint(Blueprint, ABC): + """ + Base class for Flask blueprints. + """ + + # Reference to the application object + app: 'App' + + @property + @abstractmethod + def name(self) -> str: + """ + The name of the blueprint. Will be prepended to each endpoint name. + """ + raise NotImplementedError + + @property + @abstractmethod + def import_name(self) -> str: + """ + The name of the blueprint package. Should be set to `__name__`. + """ + raise NotImplementedError + + @property + @abstractmethod + def url_prefix(self) -> str: + """ + Prefix for all URLs. + """ + raise NotImplementedError + + def __init__(self, app: 'App'): + """ + Initialize blueprint. Needs the Flask application object. + """ + super().__init__( + name=self.name, + import_name=self.import_name, + url_prefix=self.url_prefix, + ) + self.app = app + self.init_blueprint() + + @abstractmethod + def init_blueprint(self) -> None: + """ + Register child blueprints and URL rules. + """ + raise NotImplementedError + + @staticmethod + def create_view_func(view_cls: Type[View], *args, **kwargs) -> Callable: + """ + Helper function to create a view function from a `View` class using `view_cls.as_view()`. + All arguments are passed to the constructor of the view class. + """ + return view_cls.as_view(view_cls.__name__, *args, **kwargs) diff --git a/tofu_api/dependencies.py b/tofu_api/dependencies.py new file mode 100644 index 0000000..b2698c6 --- /dev/null +++ b/tofu_api/dependencies.py @@ -0,0 +1,24 @@ +from sqlalchemy.orm import Session + +from tofu_api.common.database import SQLAlchemy + + +class Dependencies: + """ + Container for dependency injection. + """ + + _dependency_cache: dict + + def __init__(self): + self._dependency_cache = {} + + # Database dependencies + + def get_sqlalchemy(self) -> SQLAlchemy: + if SQLAlchemy not in self._dependency_cache: + self._dependency_cache[SQLAlchemy] = SQLAlchemy() + return self._dependency_cache[SQLAlchemy] + + def get_db_session(self) -> Session: + return self.get_sqlalchemy().session diff --git a/tofu_api/models/__init__.py b/tofu_api/models/__init__.py new file mode 100644 index 0000000..280e6a2 --- /dev/null +++ b/tofu_api/models/__init__.py @@ -0,0 +1 @@ +from .task import Task diff --git a/tofu_api/models/task.py b/tofu_api/models/task.py new file mode 100644 index 0000000..b8acdd5 --- /dev/null +++ b/tofu_api/models/task.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, Integer, String, Text + +from tofu_api.common.database import Model + + +class Task(Model): + """ + Database model for tasks. + """ + + __tablename__ = 'tasks' + + id: int = Column(Integer, nullable=False, primary_key=True) + # TODO: created_at, modified_at + + title: str = Column(String(255), nullable=False) + description: str = Column(Text, nullable=False, default='') + + def to_dict(self) -> dict: + # TODO: Implement a generic to_dict() in the base model + return { + 'id': self.id, + 'title': self.title, + 'description': self.description, + }