The code in this post is copyright (c) 2011-2012 Todd Kennedy, and is made available under the MIT license
So in my previous post about going from a desktop app to a webpage I mentioned that I've finally settled on a similar development model to the ones I've been using at my various places of employment for the past year and a half. This isn't re-inventing the wheel, rather a bunch of helper methods for using when the Method Views for APIs pattern as talked about in the Flask docs.
There are two parts to this -- the Flask specific MethodView class and the function to register these APIs with the Flask application. The other part deals with my ORM of choice, SQLObject. SQLObject has not way to return a dict of all of an instance's values, so we'll go ahead and add that functionality to a class that we'll mixin with our SQLObject definitions.
The Flask part
The idea is to extend your view objects from this base class, so that all the default HTTP methods (well, the ones we care about) are available. By default the base class defines no methods; it's allowed list is empty, and each method simply returns an HTTP 405: Method Not Allowed error. The allowed list is used by the send_405 method to return a list of valid HTTP verbs for that endpoint, keeping in line with the HTTP specification.
When you add methods to your class, you add each verb you're going to support to the allowed list and then override the local definition of the verb you're creating.
apibase.py
There are a couple of helper methods in there, mostly around cleaning up HTML. For a product with a wide audience you might want to accept HTML in a text input field, but as we all know, accepting raw HTML is always a security risk. The make_markdown method (along with clean_html) will convert that HTML to markdown, and then strip any non-markdown related tags. This will obviously scrub any user input, but still let them have the expressive power of HTML available to them. (Note this does require having pandoc installed however).
A basic example of using this to create a method that allows anonymous users to view published blog posts might be:
from sqlobject import SQLObjectNotFound
from flask.views import MethodView
from blog_models import Entry
class AnonymousEntryAPI(MethodView, APIBase):
""" This class provides only the get method.
Without authentication, users should only be able
to read content.
endpoint: /rest/entry/
methods: get
"""
allowed = ['get',]
def get(self, entry_id, slug=None, date=None):
if entry_id is not None:
try:
entry = Entry.get(entry_id)
return self.send_200(entry.dict())
except SQLObjectNotFound:
return self.send_404()
elif date and slug:
try:
date_obj = datetime.strptime(date, '%Y-%m-%d')
next_day = date_obj+timedelta(days=1)
except ValueError:
return self.send_400('Date not in YYYY-MM-DD format')
try:
entry = Entry.select(AND(OR(Entry.q.post_on<next_day,
Entry.q.post_on>date_obj),
Entry.q.slug==slug))[0]
except (SQLObjectNotFound, IndexError):
return self.send_404()
else:
return self.send_200(entry.dict())
else:
try:
return self.send_200([entry.dict() for e in Entry.select()])
except SQLObjectNotFound:
return self.send_404()
We can ignore the endpoint part yet since we haven't registered this URL with the Flask application, but this would allow a user to retrieve either a list of objects (in this case a list of all Entry objects) or a specific instance of an Entry object, and then return it back in JSON by default of a convertible Python structure is given to to send_200 it will attempt to generate a JSON document from it. Here we're using a custom defined method for SQLObject called dict which returns a Python dict representation of the data model.
After the methods for each of our endpoints have been defined we have to register them with our Flask application, but for some of these endpoints, we may want to define multiple URLs for accessing their data. For example, a user might want to access a blog post without knowing it's ID, but rather just knowing it's publish time & slug. This method allows you to define multiple routes that will end up at the same endpoint. (Obviously the method for that verb will need to be able to locate the data using the alternative identifiers as well).
register_app_url.py
We'd register that blog view by using:
register_api(AnonymousEntryAPI, "anon_entry_api", "/rest/entry/", pk="entry_id",
pk_type="int", app=app, alt_keys=[['date', 'slug']])
in our __init__.py file (or where ever you have the app object created).
The SQLObject part
The biggest problem with using SQLObject to do this is that the extra_vars keyword is not enabled for some reason (and I've asked to no avail on the project mailing list) by default. You can either patch your own version (the patch is included in this post), or you can install my fork of SQLObject which has the patch applied. This can be done with pip install https://github.com/toddself/sqlobject/zipball/master. The patch needs to be run from the directory within the SQLObject distribution that contains col.py.
The mixin is pretty straight forward: it contains two methods dict and json. json calls the dict method but also generates the JSON for you. dict will iterate through the available parameters on the object and create a Python dict (I guess that was obvious) for all the data (including generated data provided by custom methods on an object) available on that instance.
jsonable.py
This also provides a way for you explicitly hide or show fields from the JSON representation, so private data like passwords, etc will never be exposed over the API endpoint. The only part here not yet complete is hiding specific fields from specific classes of users. This would allow you to have an object where an admin class user can edit and access fields that would be hidden from a non-authenticated or even an editor class user or similar. But that's an exercise for another day (namely the week of vacation I'm about embark upon.)
The next step once you've got this class defined is to define your object, and explain in the object what the datatype of each parameter is. We do this using the extra_vars function keyword referenced above:
class Entry(SQLObject, JSONable):
title = UnicodeCol(length=255, validator=validators.String(min=1,max=255),
extra_vars={"type": "string",
"views": ["user", "admin"]})
body = UnicodeCol(validator=validators.String(min=1),
extra_vars={"type": "string",
"views": ["user", "admin"]})
tags = RelatedJoin('Tag')
slug = UnicodeCol(length=255, default="", validator=validators.String(),
extra_vars={"type": "string", "views": ["admin"]})
post_on = DateTimeCol(default=datetime.now(),
extra_vars={"type": "datetime",
"views": ["admin", "user"]})
created_on = DateTimeCol(default=datetime.now(),
extra_vars={"type": "datetime",
"views": ["admin"]})
last_modified = DateTimeCol(default=datetime.now(),
extra_vars={"type": "datetime",
"views": ["admin"]})
draft = BoolCol(default=False,
extra_vars={"type": "boolean",
"views": ["admin"]})
author = ForeignKey('User')
deleted = BoolCol(default=False,
extra_vars={"type": "boolean",
"views": ["admin"]})
def _set_title(self, value):
self._SO_set_title(value)
self._SO_set_slug(get_slug_from_title(value))
def _get_comment_count(self):
return len(list(Comment.select(AND(Comment.q.comment_type=="Entry",
Comment.q.comment_object==self.id))))
def _get_comments(self):
return list(Comment.select(AND(Comment.q.comment_type=="Entry",
Comment.q.comment_object==self.id)))
def __str__(self):
return "%s" % self.title
class Tag(SQLObject, JSONable):
no_recurse = ['entries']
name = UnicodeCol(length=255, validator=validators.String(min=1, max=255),
extra_vars={"type": "string", "views": ["user", "admin"]})
entries = RelatedJoin('Entry')
author = ForeignKey('User')
def __str__(self):
return self.name
The no_recurse list explains to the JSONAble class that it shouldn't attempt to step down into this object and generate a dict from it (otherwise with inter-object reference loops you will end up in an endless loop, stepping down into the entry, and then back down into the tag, and then back down into the entry, etc.) The type field is "unimportant" except for when it's datetime, sqlobject or decimal since json.dumps can't, by default, convert those to base types.
And thanks to the dict method, the two custom "generated" data parameters on the Entry class, comment_count and comments are both included in the dict.