Skip to content

Commit 07461b7

Browse files
committed
Simplify API code
1 parent 86c0c30 commit 07461b7

23 files changed

+347
-410
lines changed

doku/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
from doku.utils.middlewares.hosts import host_middleware
3737
from doku.utils.session import RedisSessionInterface
3838

39+
from doku.signals.document import *
40+
3941

4042
def create_app(
4143
name="doku",

doku/blueprints/api/v1/base.py

+201
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import typing as t
2+
import warnings
3+
4+
from flask import jsonify, request
5+
from flask.scaffold import Scaffold
6+
from flask.views import MethodView
7+
from marshmallow import RAISE, ValidationError, EXCLUDE
8+
from werkzeug.exceptions import BadRequest
9+
10+
from doku.models import db
11+
from doku.models.schemas.common import ApiSchema
12+
from doku.signals import model_created, model_updated
13+
from doku.utils.db import get_or_404, get_pagination_page, get_ordering
14+
15+
16+
class BaseApiView(MethodView):
17+
model: t.Type[db.Model]
18+
schema: t.Type[ApiSchema]
19+
pk_field: str = "pk"
20+
pk_type: str = "int"
21+
22+
def get_instance(self, pk: pk_type) -> "model":
23+
return get_or_404(db.session.query(self.model).filter_by(id=pk))
24+
25+
def get(self, pk: t.Optional[pk_type]):
26+
if pk is None:
27+
return self.get_all()
28+
else:
29+
instance = self.get_instance(pk)
30+
schema = self.schema(many=False, include_request=True)
31+
return jsonify(schema.dump(instance))
32+
33+
def get_all(self):
34+
# Get page and ordering. If no specific order and direction
35+
# has been specified, None will be returned. ``order_by`` will
36+
# not complain about None being passed, so no worries there.
37+
page = get_pagination_page()
38+
ordering, order, direction = get_ordering(
39+
self.model, default_order=None, default_dir=None
40+
)
41+
# Create a copy of request arguments and drop all entries that
42+
# are pagination specific
43+
data = dict(request.args.copy())
44+
data.pop("page", None)
45+
data.pop("order", None)
46+
data.pop("dir", None)
47+
schemas = self.schema(many=True, include_request=True)
48+
pagination = (
49+
self.model.query.filter_by(**data)
50+
.order_by(ordering)
51+
.paginate(page=page, per_page=10) # noqa
52+
)
53+
result = schemas.dump(pagination.items)
54+
response = {
55+
"meta": {
56+
"pages": [page for page in pagination.iter_pages()],
57+
"has_next": pagination.has_next,
58+
"has_prev": pagination.has_prev,
59+
"next_num": pagination.next_num,
60+
"prev_num": pagination.prev_num,
61+
"page_count": pagination.pages,
62+
"per_page": pagination.per_page,
63+
},
64+
"result": result,
65+
}
66+
return jsonify(response)
67+
68+
def post(self, *, commit: bool = True):
69+
data = self.all_request_data()
70+
schema = self.schema(
71+
session=db.session,
72+
partial=True,
73+
many=isinstance(data, list),
74+
include_request=True,
75+
unknown=RAISE,
76+
)
77+
try:
78+
instance = schema.load(data)
79+
except ValidationError as e:
80+
return jsonify(e.messages), BadRequest.code
81+
db.session.add(instance)
82+
model_created.send(self.model, instance=instance)
83+
if commit:
84+
db.session.commit()
85+
result = schema.dump(instance)
86+
return jsonify(result), 200
87+
88+
def put(self, pk: t.Optional[pk_type], *, commit: bool = True):
89+
return self.update(pk=pk, commit=commit)
90+
91+
def patch(self, pk: t.Optional[pk_type], *, commit: bool = True):
92+
return self.update(pk=pk, commit=commit)
93+
94+
def delete(self, pk: pk_type, commit: bool = True):
95+
instance = self.get_instance(pk)
96+
db.session.delete(instance)
97+
if commit:
98+
db.session.commit()
99+
return jsonify({"success": True})
100+
101+
def update(self, *, pk: t.Optional[pk_type] = None, commit: bool = True):
102+
data = self.all_request_data()
103+
if pk is not None:
104+
instance = self.get_instance(pk)
105+
schema = self.schema(
106+
instance=instance,
107+
session=db.session,
108+
unknown=EXCLUDE,
109+
include_request=True,
110+
many=False,
111+
)
112+
else:
113+
schema = self.schema(
114+
unknown=EXCLUDE,
115+
partial=True,
116+
session=db.session,
117+
many=isinstance(data, list),
118+
include_request=True,
119+
)
120+
121+
try:
122+
instance = schema.load(data)
123+
except ValidationError as e:
124+
return jsonify(e.messages), BadRequest.code
125+
126+
# Send signals that instance will be updated
127+
if isinstance(instance, list):
128+
for _instance in instance:
129+
model_updated.send(self.model, instance=_instance)
130+
else:
131+
model_updated.send(self.model, instance=instance)
132+
133+
if commit:
134+
db.session.commit()
135+
result = schema.dump(instance)
136+
return jsonify(result)
137+
138+
@staticmethod
139+
def all_request_data(include_args=False) -> t.Union[dict, list]:
140+
"""All Request Data
141+
142+
Get all request data including ``args``, ``form`` and ``json``.
143+
144+
:param include_args: Whether to include request ``args``. This
145+
might be handy for get requests. Disabled by default.
146+
"""
147+
base_data = request.values if include_args else request.form
148+
data = dict(base_data.copy())
149+
if request.json is not None:
150+
if isinstance(request.json, list):
151+
return request.json
152+
data.update(request.json)
153+
return data
154+
155+
@classmethod
156+
def register(cls, app: Scaffold, name: str, url: str):
157+
if not url.endswith("/"):
158+
warnings.warn(
159+
f"URL '{url}' does not end with a trailing slash ('/').", UserWarning
160+
)
161+
url = f"{url}/"
162+
view = cls.as_view(name)
163+
# Get all entries
164+
app.add_url_rule(url, defaults=dict(pk=None), view_func=view, methods=["GET"])
165+
# Create entry
166+
app.add_url_rule(url, view_func=view, methods=["POST"])
167+
# Methods on existing entries
168+
app.add_url_rule(
169+
f"{url}<{cls.pk_type}:{cls.pk_field}>/",
170+
view_func=view,
171+
methods=["GET", "PUT", "PATCH", "DELETE"]
172+
)
173+
# Bulk operations
174+
app.add_url_rule(
175+
url,
176+
defaults=dict(pk=None),
177+
view_func=view,
178+
methods=["PUT", "PATCH"]
179+
)
180+
181+
182+
def api_view_factory(
183+
view_model: t.Type[db.Model],
184+
view_schema: t.Type[ApiSchema],
185+
view_pk_field: str = BaseApiView.pk_field,
186+
view_pk_type: str = BaseApiView.pk_type,
187+
register: bool = False,
188+
register_args: t.Optional[tuple] = None,
189+
) -> t.Type[BaseApiView]:
190+
class ApiView(BaseApiView):
191+
model: t.Type[db.Model] = view_model
192+
schema: t.Type[ApiSchema] = view_schema
193+
pk_field: str = view_pk_field
194+
pk_type: str = view_pk_type
195+
196+
if register:
197+
if not register_args:
198+
raise ValueError("'register_args' is required for registering the API view")
199+
ApiView.register(*register_args)
200+
201+
return ApiView

doku/blueprints/api/v1/document.py

+7-25
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import json
22
from datetime import datetime
33

4-
from werkzeug.exceptions import BadRequest, TooManyRequests
5-
from flask import Blueprint, current_app, jsonify, request, session
64
from celery.result import AsyncResult
5+
from flask import Blueprint, current_app, jsonify, request, session
76
from flask_babel import format_timedelta, format_datetime
7+
from werkzeug.exceptions import BadRequest, TooManyRequests
88

9+
from doku.blueprints.api.v1.base import api_view_factory
910
from doku.models import db
1011
from doku.models.document import Document
1112
from doku.models.schemas import DocumentSchema
@@ -16,29 +17,10 @@
1617
bp = Blueprint("document", __name__)
1718

1819

19-
@bp.route("/", methods=["POST"])
20-
def create():
21-
return DocumentSchema.create(commit=True)
22-
23-
24-
@bp.route("/", methods=["PUT", "PATCH"])
25-
def update():
26-
return DocumentSchema.update()
27-
28-
29-
@bp.route("/", methods=["GET"])
30-
def get_all():
31-
return DocumentSchema.get_all()
32-
33-
34-
@bp.route("/<int:document_id>/", methods=["GET"])
35-
def get(document_id: int):
36-
return DocumentSchema.get(document_id)
37-
38-
39-
@bp.route("/<int:document_id>/", methods=["DELETE"])
40-
def delete(document_id: int):
41-
return DocumentSchema.delete(document_id)
20+
DocumentApiView = api_view_factory(
21+
Document, DocumentSchema,
22+
register=True, register_args=(bp, "api", "/")
23+
)
4224

4325

4426
@bp.route("/ids", methods=["GET"])

doku/blueprints/api/v1/resource.py

+22-21
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,32 @@
1-
from flask import Blueprint
1+
import os
2+
import typing as t
23

4+
from flask import Blueprint, current_app, jsonify
5+
6+
from doku.blueprints.api.v1.base import BaseApiView
7+
from doku.models import db
8+
from doku.models.resource import Resource
39
from doku.models.schemas import ResourceSchema
4-
from doku.utils.decorators import login_required
10+
from doku.models.schemas.common import ApiSchema
511

612
bp = Blueprint("resource", __name__)
713

814

9-
@bp.route("/", methods=["POST"])
10-
def create():
11-
raise NotImplementedError()
12-
13-
14-
@bp.route("/", methods=["PUT", "PATCH"])
15-
def update():
16-
return ResourceSchema.update()
17-
18-
19-
@bp.route("/", methods=["GET"])
20-
def get_all():
21-
return ResourceSchema.get_all()
15+
class ResourceApiView(BaseApiView):
16+
model: t.Type[db.Model] = Resource
17+
schema: t.Type[ApiSchema] = ResourceSchema
2218

19+
def post(self, *, commit: bool = True):
20+
raise NotImplementedError()
2321

24-
@bp.route("/<int:resource_id>/", methods=["GET"])
25-
def get(resource_id: int):
26-
return ResourceSchema.get(resource_id)
22+
def delete(self, pk: int, commit: bool = True):
23+
instance = self.get_instance(pk)
24+
filename = instance.filename
25+
os.remove(os.path.join(current_app.config["UPLOAD_FOLDER"], filename))
26+
db.session.delete(instance)
27+
if commit:
28+
db.session.commit()
29+
return jsonify({"success": True})
2730

2831

29-
@bp.route("/<int:resource_id>/", methods=["DELETE"])
30-
def delete(resource_id: int):
31-
return ResourceSchema.delete(resource_id)
32+
ResourceApiView.register(bp, "api", "/")

doku/blueprints/api/v1/snippet.py

+6-23
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,13 @@
11
from flask import Blueprint
22

3+
from doku.blueprints.api.v1.base import api_view_factory
4+
from doku.models.base import Snippet
35
from doku.models.schemas import SnippetSchema
46

57
bp = Blueprint("snippet", __name__)
68

79

8-
@bp.route("/", methods=["POST"])
9-
def create():
10-
return SnippetSchema.create(commit=True)
11-
12-
13-
@bp.route("/", methods=["PUT", "PUSH"])
14-
def update():
15-
return SnippetSchema.update()
16-
17-
18-
@bp.route("/", methods=["GET"])
19-
def get_all():
20-
return SnippetSchema.get_all()
21-
22-
23-
@bp.route("/<int:snippet_id>/", methods=["GET"])
24-
def get(snippet_id: int):
25-
return SnippetSchema.get(snippet_id)
26-
27-
28-
@bp.route("/<int:snippet_id>/", methods=["DELETE"])
29-
def delete(snippet_id: int):
30-
return SnippetSchema.delete(snippet_id)
10+
SnippetApiView = api_view_factory(
11+
Snippet, SnippetSchema,
12+
register=True, register_args=(bp, "api", "/")
13+
)

doku/blueprints/api/v1/stylesheet.py

+5-23
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from werkzeug.datastructures import FileStorage
44
from werkzeug.exceptions import BadRequest
55

6+
from doku.blueprints.api.v1.base import api_view_factory
67
from doku.models import db
78
from doku.models.schemas import StylesheetSchema
89
from doku.models.template import Stylesheet
@@ -44,26 +45,7 @@ def upload(stylesheet_id: int):
4445
return jsonify(result)
4546

4647

47-
@bp.route("/", methods=["PUT", "PATCH"])
48-
def update():
49-
return StylesheetSchema.update()
50-
51-
52-
@bp.route("/", methods=["POST"])
53-
def create():
54-
return StylesheetSchema.create()
55-
56-
57-
@bp.route("/", methods=["GET"])
58-
def get_all():
59-
return StylesheetSchema.get_all()
60-
61-
62-
@bp.route("/<int:stylesheet_id>/", methods=["GET"])
63-
def get(stylesheet_id: int):
64-
return StylesheetSchema.get(stylesheet_id)
65-
66-
67-
@bp.route("/<int:stylesheet_id>/", methods=["DELETE"])
68-
def delete(stylesheet_id: int):
69-
return StylesheetSchema.delete(stylesheet_id)
48+
StylesheetApiView = api_view_factory(
49+
Stylesheet, StylesheetSchema,
50+
register=True, register_args=(bp, "api", "/")
51+
)

0 commit comments

Comments
 (0)