Add SQLAlchemy and database boilerplate code
This commit is contained in:
parent
2da6e43797
commit
31df889dcb
15 changed files with 454 additions and 60 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'/<int:task_id>',
|
||||
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/<int:task_id>` 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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
0
tofu_api/common/__init__.py
Normal file
0
tofu_api/common/__init__.py
Normal file
3
tofu_api/common/database/__init__.py
Normal file
3
tofu_api/common/database/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .metadata import metadata_obj
|
||||
from .model import Model
|
||||
from .sqlalchemy import SQLAlchemy
|
||||
17
tofu_api/common/database/metadata.py
Normal file
17
tofu_api/common/database/metadata.py
Normal file
|
|
@ -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)
|
||||
10
tofu_api/common/database/model.py
Normal file
10
tofu_api/common/database/model.py
Normal file
|
|
@ -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)
|
||||
87
tofu_api/common/database/sqlalchemy.py
Normal file
87
tofu_api/common/database/sqlalchemy.py
Normal file
|
|
@ -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)
|
||||
1
tofu_api/common/rest/__init__.py
Normal file
1
tofu_api/common/rest/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .base_blueprint import BaseBlueprint
|
||||
72
tofu_api/common/rest/base_blueprint.py
Normal file
72
tofu_api/common/rest/base_blueprint.py
Normal file
|
|
@ -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)
|
||||
24
tofu_api/dependencies.py
Normal file
24
tofu_api/dependencies.py
Normal file
|
|
@ -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
|
||||
1
tofu_api/models/__init__.py
Normal file
1
tofu_api/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .task import Task
|
||||
25
tofu_api/models/task.py
Normal file
25
tofu_api/models/task.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue