Skip to content

Commit

Permalink
Updates docs. Removes primary key configuration, uses model inspectio…
Browse files Browse the repository at this point in the history
…n instead. (#9)
  • Loading branch information
dtiesling authored Nov 28, 2023
1 parent 52a3cf2 commit 570214c
Show file tree
Hide file tree
Showing 11 changed files with 208 additions and 29 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added docs/docs/img/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
141 changes: 141 additions & 0 deletions docs/docs/nesting_apis.md
Original file line number Diff line number Diff line change
@@ -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/<ID\>/ | GET | Fetch a parent |
| /api/parents/<ID\>/ | PUT | Update a parent |
| /api/parents/<ID\>/ | PATCH | Patch a parent |
| /api/parents/<ID\>/ | DELETE | Delete a parent |
| /api/parents/<ID\>/children/ | GET | List all of a parent's children |
| /api/parents/<ID\>/children/ | POST | Create a child foreign keyed to a parent. |
| /api/parents/<ID\>/children/<ID\>/ | GET | Fetch a child |
| /api/parents/<ID\>/children/<ID\>/ | PUT | Update a child |
| /api/parents/<ID\>/children/<ID\>/ | PATCH | Patch a child |
| /api/parents/<ID\>/children/<ID\>/ | 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]
```
33 changes: 21 additions & 12 deletions docs/docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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/\<ID>/ | GET | Fetch a single teacher |
| /api/teachers/\<ID>/ | PUT | Update a single teacher |
| /api/teachers/\<ID>/ | PATCH | Patch a single teacher |
| /api/teachers/\<ID>/ | DELETE | Delete a single teacher |
| /api/teachers/<ID\>/ | GET | Fetch a single teacher |
| /api/teachers/<ID\>/ | PUT | Update a single teacher |
| /api/teachers/<ID\>/ | PATCH | Patch a single teacher |
| /api/teachers/<ID\>/ | DELETE | Delete a single teacher |



3 changes: 3 additions & 0 deletions docs/docs/stylesheets/extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
15 changes: 11 additions & 4 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
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
- navigation.expand
- navigation.path
- toc.follow
- navigation.top
- search.suggest
- search.highlight
- search.share
- content.code.annotate
palette:
- scheme: default
primary: custom
Expand All @@ -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
2 changes: 1 addition & 1 deletion examples/00_quickstart/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
18 changes: 16 additions & 2 deletions src/flask_muck/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 + "/"
Expand All @@ -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:
Expand Down
10 changes: 4 additions & 6 deletions src/flask_muck/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -401,7 +399,7 @@ def add_rules_to_blueprint(cls, blueprint: Blueprint) -> None:

# Detail, Update, Patch, Delete endpoints - GET, PUT, PATCH, DELETE on /<resource_id>
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"},
)

0 comments on commit 570214c

Please sign in to comment.