diff --git a/README.md b/README.md index 7d0ed51..bce4009 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ ![Logo](https://dtiesling.github.io/flask-muck/img/logo.png) +With Flask-Muck you don't have to worry about the CRUD. + Flask-Muck is a batteries-included framework for automatically generating RESTful APIs with Create, Read, Update and Delete (CRUD) endpoints in a Flask/SqlAlchemy application stack. -With Flask-Muck you don't have to worry about the CRUD. + ```python from flask import Blueprint diff --git a/docs/docs/img/favicon.png b/docs/docs/img/favicon.png new file mode 100644 index 0000000..3657873 Binary files /dev/null and b/docs/docs/img/favicon.png differ diff --git a/docs/docs/index.md b/docs/docs/index.md index 5c6a658..34d4606 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -6,7 +6,7 @@ [![types - Mypy](https://img.shields.io/badge/types-Mypy-blue.svg)](https://github.com/python/mypy) [![License - MIT](https://img.shields.io/badge/license-MIT-9400d3.svg)](https://spdx.org/licenses/) - +With Flask-Muck you don't have to worry about the CRUD. Flask-Muck is a batteries-included framework for automatically generating RESTful APIs with Create, Read, Update and Delete (CRUD) endpoints in a Flask/SqlAlchemy application stack. diff --git a/docs/docs/nesting_apis.md b/docs/docs/nesting_apis.md new file mode 100644 index 0000000..e431663 --- /dev/null +++ b/docs/docs/nesting_apis.md @@ -0,0 +1,141 @@ +# Nesting Resource APIs + +Nesting hierarchical resources in a REST API is a common practice. Flask-Muck provides out-of-the-box support for +generating nested APIs if the SqlAlchemy models are related by a basic foreign key relationship. Nested APIs automatically +handle filtering child resources and supplying the parent id as input during the Create operation. + +Creating the nested relationship is as simple as setting the `parent` class variable of a FlaskMuckApiView to another +FlaskMuckApiView who's `Model` has a valid foreign key relationship. + +```python +from flask import Blueprint + +from flask_muck import FlaskMuckApiView +from myapp import db +from myapp.models import Parent, Child +from myapp.schemas import ParentSchema, ChildSchema + +class ParentApiView(FlaskMuckApiView): + api_name = "parents" + session = db.session + Model = Parent + ResponseSchema = ParentSchema + CreateSchema = Parentchema + PatchSchema = Parentchema + UpdateSchema = ParentSchema + +class ChildApiView(FlaskMuckApiView): + api_name = "children" + session = db.session + parent = ParentApiView#(1)! + Model = Child#(2)! + ResponseSchema = ChildSchema + CreateSchema = ChildSchema + PatchSchema = ChildSchema + UpdateSchema = ChildSchema + +blueprint = Blueprint("api", __name__, url_prefix="/api/") +ParentApiView.add_rules_to_blueprint(blueprint) +ChildApiView.add_rules_to_blueprint(blueprint) +``` + +1. Setting the `parent` class variable to another FlaskMuckApiView is all that is needed to set up nesting. +2. The `Child` model must have a foreign key column that references the `Parent` model. + +This produces the following nested api resources. + +| URL Path | Method | Description | +|------------------------------------|--------|-------------------------------------------| +| /api/parents/ | GET | List all parents | +| /api/parents/ | POST | Create a parent | +| /api/parents// | GET | Fetch a parent | +| /api/parents// | PUT | Update a parent | +| /api/parents// | PATCH | Patch a parent | +| /api/parents// | DELETE | Delete a parent | +| /api/parents//children/ | GET | List all of a parent's children | +| /api/parents//children/ | POST | Create a child foreign keyed to a parent. | +| /api/parents//children// | GET | Fetch a child | +| /api/parents//children// | PUT | Update a child | +| /api/parents//children// | PATCH | Patch a child | +| /api/parents//children// | DELETE | Delete a child | + +!!! Tip + Nesting APIs works recursively so you don't have to stop at one level of nesting. + +!!! Warning + If your models are not using standard integer or UUID primary keys nested APIs may not work correctly. + + +## Complete Example +!!! note + This example expands on the example in the [quickstart](quickstart.md). If you have not read through the + [quickstart](quickstart.md) this will make more sense if you do. + +Let's say that we wanted to add a nested endpoint to our teacher detail endpoint from the quickstart that would allow us +to work with all of a teacher's students. + +Below are the models, schemas and views we would need. + +```python title="myapp/models.py" +from myapp import db + +class Teacher(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, nullable=False) + years_teaching = db.Column(db.Integer) + +class Student(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, nullable=False) + parent_id = db.Column(db.ForeignKey(Teacher.id)) + parent = db.relationship(Teacher) +``` + +```python title="myapp/schemas.py" +from marshmallow import Schema +from marshmallow import fields as mf + + +class TeacherSchema(Schema): + id = mf.Integer(dump_only=True) + name = mf.String(required=True) + years_teaching = mf.Integer() + +class StudentSchema(Schema): + id = mf.Integer(dump_only=True) + name = mf.String(required=True) +``` + +```python title="myapp/views.py" +from flask_muck import FlaskMuckApiView +from myapp import db +from myapp.auth.decorators import login_required +from myapp.models import Teacher, Student +from myapp.schemas import TeacherSchema, StudentSchema + + +class BaseApiView(FlaskMuckApiView): + session = db.session + decorators = [login_required] + + +class TeacherApiView(BaseApiView): + api_name = "teachers" + Model = Teacher + ResponseSchema = TeacherSchema + CreateSchema = TeacherSchema + PatchSchema = TeacherSchema + UpdateSchema = TeacherSchema + searchable_columns = [Teacher.name] + + +class StudentApiView(BaseApiView): + api_name = "student" + Model = Student + parent = TeacherApiView + ResponseSchema = StudentSchema + CreateSchema = StudentSchema + PatchSchema = StudentSchema + UpdateSchema = StudentSchema + searchable_columns = [Student.name] +``` \ No newline at end of file diff --git a/docs/docs/quickstart.md b/docs/docs/quickstart.md index db97343..11242e1 100644 --- a/docs/docs/quickstart.md +++ b/docs/docs/quickstart.md @@ -41,7 +41,8 @@ class BaseApiView(FlaskMuckApiView): decorators = [login_required] ``` -NOTE: For the remainder of this guide we'll assume the usage of the [Flask-SqlAlchemy](https://flask-sqlalchemy.palletsprojects.com/en/3.1.x/quickstart/#quick-start) extension. +!!! note + For the remainder of this guide we'll assume usage of the [Flask-SqlAlchemy](https://flask-sqlalchemy.palletsprojects.com/en/3.1.x/quickstart/#quick-start) extension. ## Create SqlAlchemy Model Flask-Muck requires the use of SqlAlchemy's [declarative system](). If you are not using the declarative system you will @@ -80,15 +81,23 @@ Inherit from the project's base api view class and define the required class var ```python class TeacherApiView(BaseApiView): - api_name = "teachers" # Name used as the url endpoint in the REST API. - Model = Teacher # Model class that will be queried and updated by this API. - ResponseSchema = TeacherSchema # Marshmallow schema used to serialize and Teachers returned by the API. - CreateSchema = TeacherSchema # Marshmallow schema used to validate payload data sent to the Create endpoint. - PatchSchema = TeacherSchema # Marshmallow schema used to validate payload data sent to the Patch endpoint. - UpdateSchema = TeacherSchema # Marshmallow schema used to validate payload data sent to the Update endpoint. - searchable_columns = [Teacher.name] # List of model columns that can be searched when listing Teachers using the API. + api_name = "teachers" #(1)! + Model = Teacher #(2)! + ResponseSchema = TeacherSchema #(3)! + CreateSchema = TeacherSchema #(4)! + PatchSchema = TeacherSchema #(5)! + UpdateSchema = TeacherSchema #(6)! + searchable_columns = [Teacher.name] #(7)! ``` +1. Name used as the url endpoint in the REST API. +2. Model class that will be queried and updated by this API. +3. Marshmallow schema used to serialize and Teachers returned by the API. +4. Marshmallow schema used to validate payload data sent to the Create endpoint. +5. Marshmallow schema used to validate payload data sent to the Patch endpoint. +6. Marshmallow schema used to validate payload data sent to the Update endpoint. +7. List of model columns that can be searched when listing Teachers using the API. + ## Add URL rules to a Flask Blueprint. The final step is to add the correct URL rules to an existing [Flask Blueprint](https://flask.palletsprojects.com/en/3.0.x/blueprints/) object. A classmethod is included that handles adding all necessary rules to the given Blueprint. @@ -106,10 +115,10 @@ This produces the following views, a standard REST API! |----------------------|--------|----------------------------------------------------------------------------------------------------| | /api/teachers/ | GET | List all teachers - querystring options available for sorting, filtering, searching and pagination | | /api/teachers/ | POST | Create a teacher | -| /api/teachers/\/ | GET | Fetch a single teacher | -| /api/teachers/\/ | PUT | Update a single teacher | -| /api/teachers/\/ | PATCH | Patch a single teacher | -| /api/teachers/\/ | DELETE | Delete a single teacher | +| /api/teachers// | GET | Fetch a single teacher | +| /api/teachers// | PUT | Update a single teacher | +| /api/teachers// | PATCH | Patch a single teacher | +| /api/teachers// | DELETE | Delete a single teacher | diff --git a/docs/docs/stylesheets/extra.css b/docs/docs/stylesheets/extra.css index 9576f3a..792009e 100644 --- a/docs/docs/stylesheets/extra.css +++ b/docs/docs/stylesheets/extra.css @@ -2,4 +2,7 @@ --md-primary-fg-color: #3a677c; --md-primary-fg-color--light: #3a677c; --md-primary-fg-color--dark: #3a677c; + --md-accent-fg-color: #763d41; + --md-accent-fg-color--light: #763d41; + --md-accent-fg-color--dark: #763d41; } \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index af79b4b..1ef5388 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,7 +1,9 @@ site_name: Flask-Muck Documentation +repo_url: https://github.com/dtiesling/flask-muck theme: name: material logo: img/icon.png + favicon: img/favicon.png features: - navigation.instant - navigation.sections @@ -9,6 +11,10 @@ theme: - navigation.path - toc.follow - navigation.top + - search.suggest + - search.highlight + - search.share + - content.code.annotate palette: - scheme: default primary: custom @@ -24,17 +30,18 @@ theme: toggle: icon: material/brightness-4 name: Switch to light mode - plugins: - search - markdown_extensions: - attr_list - + - admonition + - abbr + - md_in_html + - pymdownx.superfences extra_css: - stylesheets/extra.css - nav: - About: index.md - Installation: installation.md - Quick Start: quickstart.md + - Nesting APIs: nesting_apis.md diff --git a/examples/00_quickstart/app.py b/examples/00_quickstart/app.py index a2f2c05..df53240 100644 --- a/examples/00_quickstart/app.py +++ b/examples/00_quickstart/app.py @@ -4,7 +4,7 @@ from marshmallow import fields as mf from sqlalchemy.orm import DeclarativeBase -from flask_muck.views import FlaskMuckApiView +from flask_muck import FlaskMuckApiView # Create a Flask app app = Flask(__name__) diff --git a/pyproject.toml b/pyproject.toml index 17411f5..c6bd2ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,12 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] +dependencies = [ + "Flask >= 2.0.0", + "sqlalchemy >= 1.4.0", + "webargs >= 8.0.0", + "marshmallow >= 3.15.0", +] [project.urls] "Homepage" = "https://github.com/dtiesling/flask-muck" @@ -46,13 +52,12 @@ python = "^3.9" Flask = "^2.0.0" sqlalchemy = "^2.0.23" webargs = "^8.3.0" -types-requests = "^2.31.0.10" marshmallow = "^3.20.1" [tool.poetry.group.dev.dependencies] mypy = "^1.6.1" -types-flask = "^1.1.6" types-requests = "^2.31.0.10" +types-flask = "^1.1.6" sqlalchemy-stubs = "^0.4" pytest = "^7.4.3" flask-login = "^0.6.3" diff --git a/src/flask_muck/utils.py b/src/flask_muck/utils.py index a58dd1e..7641f90 100644 --- a/src/flask_muck/utils.py +++ b/src/flask_muck/utils.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Optional, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union, Literal from flask import request from sqlalchemy import Column, inspect @@ -17,7 +17,7 @@ def get_url_rule(muck_view: type[FlaskMuckApiView], append_rule: Optional[str]) if append_rule: rule = f"{rule}/{append_rule}" if muck_view.parent: - rule = f"<{muck_view.parent.primary_key_type.__name__}:{muck_view.parent.api_name}_id>/{rule}" + rule = f"<{get_pk_type(muck_view.parent.Model)}:{muck_view.parent.api_name}_id>/{rule}" return get_url_rule(muck_view.parent, rule) if not rule.endswith("/"): rule = rule + "/" @@ -39,6 +39,20 @@ def get_fk_column( ) +def get_pk_column(model: SqlaModelType) -> Column: + """Returns the Primary Key column for a model.""" + return model.__table__.primary_key.columns.values()[0] + + +def get_pk_type(model: SqlaModelType) -> Literal["str", "int"]: + """Returns either "int" or "str" to describe the Primary Key type for a model. Used for building URL rules.""" + pk_column = get_pk_column(model) + if issubclass(pk_column.type.python_type, (int, float)): + return "int" + else: + return "str" + + def get_query_filters_from_request_path( view: Union[type[FlaskMuckApiView], FlaskMuckApiView], query_filters: list ) -> list: diff --git a/src/flask_muck/views.py b/src/flask_muck/views.py index e0ece3b..75a9084 100644 --- a/src/flask_muck/views.py +++ b/src/flask_muck/views.py @@ -28,6 +28,8 @@ from flask_muck.utils import ( get_url_rule, get_query_filters_from_request_path, + get_pk_column, + get_pk_type, ) logger = getLogger(__name__) @@ -67,8 +69,6 @@ class FlaskMuckApiView(MethodView): default_pagination_limit: int = 20 one_to_one_api: bool = False allowed_methods: set[str] = {"GET", "POST", "PUT", "PATCH", "DELETE"} - primary_key_column: str = "id" - primary_key_type: Union[type[int], type[str]] = int operator_separator: str = "__" @property @@ -108,9 +108,7 @@ def _get_resource(cls, resource_id: Optional[ResourceId]) -> SqlaModel: query = cls._get_base_query() if cls.one_to_one_api: return query.one() - return query.filter( - getattr(cls.Model, cls.primary_key_column) == resource_id - ).one() + return query.filter(get_pk_column(cls.Model) == resource_id).one() def _get_clean_filter_data(self, filters: str) -> JsonDict: try: @@ -401,7 +399,7 @@ def add_rules_to_blueprint(cls, blueprint: Blueprint) -> None: # Detail, Update, Patch, Delete endpoints - GET, PUT, PATCH, DELETE on / blueprint.add_url_rule( - f"{url_rule}/<{cls.primary_key_type.__name__}:resource_id>/", + f"{url_rule}/<{get_pk_type(cls.Model)}:resource_id>/", view_func=api_view, methods={"GET", "PUT", "PATCH", "DELETE"}, )