Open jace opened 9 years ago
Perhaps the decorators should be named create
, read
, update
and delete
to reflect the CRUD
acronym.
This is still too verbose. A full set of CRUD routes is still 20-30 lines of boilerplate. CrudView should be designed to produce boilerplate automatically, allowing the user to override only as necessary.
As we've learnt from hasgeek/coaster#150 (StateManager), preserving flexibility for special behaviour is important, so that has to be the accounted for. We need these endpoints in order of priority, with these possible outcomes:
Notes:
The distinction between HTTP 200/201 and 303 depends on how the request was made. A browser request must send the user to an appropriate destination to avoid disrupting user flow. A REST request may be confused between (a) a successful operation, and (b) the API endpoint itself being relocated.
Returning a form (for create/update) and returning a form's validation errors are so similar in typical use that we use the same method for both. We could similarly merge the Create and Update code, using a blank object where one does not exist.
The sample code above does not account for the is_url_for
decorator introduced in hasgeek/coaster#77.
Proper CRUD requires using the HTTP verbs GET
, PUT
, POST
and DELETE
instead of endpoint suffixes like /new
, /edit
and /delete
. However, browsers can only send GET
and POST
requests, so we need a compromise:
POST
in lieu of PUT
and DELETE
but send an additional __method__
field in the form as an indicator to the backend.Mapped to the seven handlers above, this gets us (assuming a base URL of /parent/object
):
Action | REST | Browser |
---|---|---|
Read | GET /parent/object |
GET /parent/object |
Update-Post | PUT /parent/object |
POST /parent/object/edit |
Update-Get | ??? | GET /parent/object/edit |
Create-Post | POST /parent |
POST /parent/new |
Create-Get | ??? | GET /parent/new |
Delete-Post | DELETE /parent/object |
POST /parent/object/delete |
Delete-Get | ??? | GET /parent/object/delete |
Flask provides a MethodView that makes the REST URLs easier to implement, but it's not good enough as we have nowhere to serve Update-Get, Create-Get and Delete-Get from.
If we take the register_api
example from Flask and make it a classmethod on the CrudView
class, it'll go a long way towards solving this.
CrudView needs a way to connect a URL to a loaded instance. load_models
is showing its age, so we should consider something else better suited for CrudView. Consider the pending tickets:
/new
and /edit
can be combined into a single handlername
or suuid
)CrudView could be loader-agnostic by doing something like this:
def returns_loaded(parent, instance):
return instance
class MyView(CrudView):
model = MyModel
parent = ParentModel
form = MyForm
loader = load_models(
(ParentModel: {'name': 'parent'}, 'parent'),
(MyModel, {'name': 'identity', 'parent': 'parent'}, 'instance')
)(returns_loaded)
MyView.register_views(app, '/<parent>')
This sample code should be the entire boilerplate for registering CRUD views with default handlers.
To ensure form.populate_obj(obj)
does not cause damage, CrudView must always use a role access proxy (from hasgeek/coaster#109). This code should work transparently:
proxy = obj.access_for(g.user)
form.populate_obj(proxy)
Do note that g.user
may become current_user
with hasgeek/flask-lastuser#24, and the current_user
symbol will become available for import in Baseframe when #68 is implemented.
CrudView should also account for form context defined in #112.
Another API proposal:
docview = ModelView(app, Document)
# docview is a helper for making views and offers the following methods:
# add_create, add_read, add_update, add_delete, add_view
docview.add_read(
route='/<parent>/<document>',
query=lambda parent, document: Document.query.join(DocParent).filter(Document.name == document, Document.parent.name == parent),
template='document.html.jinja2', # Passed to render_with, so this can be a dictionary as well
json=True, # Adds JSON output support
permission='view', # Optional (tested with `permission in document.permissions(current_auth.actor)`)
roles={'all'}, # Optional (tested with `roles.intersection(document.current_roles)`)
decorators=[], # Optional decorators to be applied to the view (can be `requestargs`, `cors`, etc)
)
add_read
will accept either query
or loader
. Given a query
, it will have .first_or_404()
applied to it. add_view
makes most parameters optional (except route
) and will reuse whatever was given to add_read
. add_update
and add_delete
will accept relative routes (without a leading /
) such as edit
and delete
and will append them to add_read
's route.
Problem: routes and route validation can be complicated, as this snippet from Hasjob shows:
@app.route('/<domain>/<hashid>', methods=('GET', 'POST'), subdomain='<subdomain>')
@app.route('/<domain>/<hashid>', methods=('GET', 'POST'))
@app.route('/view/<hashid>', defaults={'domain': None}, methods=('GET', 'POST'), subdomain='<subdomain>')
@app.route('/view/<hashid>', defaults={'domain': None}, methods=('GET', 'POST'))
def jobdetail(domain, hashid):
is_siteadmin = lastuser.has_permission('siteadmin')
query = JobPost.fetch(hashid).options(
db.subqueryload('locations'), db.subqueryload('taglinks'))
post = query.first_or_404()
# If we're on a board (that's not 'www') and this post isn't on this board,
# redirect to (a) the first board it is on, or (b) on the root domain (which may
# be the 'www' board, which is why we don't bother to redirect if we're currently
# in the 'www' board)
if g.board and g.board.not_root and post.link_to_board(g.board) is None:
blink = post.postboards.first()
if blink:
return redirect(post.url_for(subdomain=blink.board.name, _external=True))
else:
return redirect(post.url_for(subdomain=None, _external=True))
# If this post is past pending state and the domain doesn't match, redirect there
if post.status not in POSTSTATUS.UNPUBLISHED and post.email_domain != domain:
return redirect(post.url_for(), code=301)
if post.status in POSTSTATUS.UNPUBLISHED:
if not ((g.user and post.admin_is(g.user))):
abort(403)
if post.status in POSTSTATUS.GONE:
abort(410)
Where do we accommodate (a) additional routes and (b) view validation?
route
parameter can accept a dictionary (as passed to app.route
) or an iterable of dictionaries (multiple calls to app.route
)docread = docview.add_read(…)
@docread.validate
def docread_validate(context, document):
pass
@docread.prepare
def docread_prepare(context, document):
# This is what happens if no `prepare` handler is provided
return document.current_access()
(context
will be docread
itself, so it acts like a self
parameter. This may be unnecessary though.)
Problem: The query
/loader
here can return only one item. This is normally not a problem because most objects have a .parent
(or similar) object leading to the parent. A notable exception to this is the CommentSpace
model in Funnel, which can be attached to multiple models and so has no way to know where it is attached except by querying all the models for a reference to itself. Effectively, the owner of a comment space cannot be discovered from the comment space. However, since admin permissions are inherited from the owner, we have the basis for how load_models
and PermissionMixin
work: each of the models in the chain is queried one at a time (and not in a joined load), checked for permissions, and these are then passed to the next item in the chain as the inherited
parameter.
We haven't had this ambiguous parentage problem anywhere else (nodular
even went out of the way to avoid it), but that doesn't mean it won't arise again.
Here is yet another way to think about views. We make these assumptions:
We've had an early attempt at view consolidation with Nodular's NodeView. It allowed view construction like this:
class MyNodeView(NodeView):
@NodeView.route('/')
def index(self):
return u'index view'
@NodeView.route('/edit', methods=['GET', 'POST'])
@NodeView.route('/', methods=['PUT'])
@NodeView.requires_permission('edit', 'siteadmin')
def edit(self):
return u'edit view'
@NodeView.route('/delete', methods=['GET', 'POST'])
@NodeView.route('/', methods=['DELETE'])
@NodeView.requires_permission('delete', 'siteadmin')
def delete(self):
return u'delete view'
NodeView is heavy, creating a new router for the view. The app's router lands on /<path>/<to>/<object>/<path:everything>
and the NodeView router then kicks in, applying this <path:everything>
to the internal routes. We can make a lighter version of this that plugs directly into the main app's routes.
CRUD views need a foundation. This is coming in hasgeek/coaster#167 with class-based views.
CRUD views are a recurring pattern in HasGeek apps:
/new
handler exists that presents a blank form usingrender_form
and creates a new instance if the form validates, following which the user is redirected to the new instance./edit
handler exists that loads the object into the form and otherwise behaves exactly like/new
./delete
handler exists that asks for confirmation, then deletes the object and redirects to the parent object.This pattern can be abstracted away so that (a) we can get past the pain of making views and (b) add new features in a centralised place instead of updating every view in every app. Sample usage: