February 8, 2017

Using TastyPie to Work With Non-ORM Objects

Industry Dive is working on a native mobile app for Android and iOS! After contracting a third party to build the app itself, we got to work creating an API our mobile app developers could use to interface with our web applications. We chose TastyPie as our API library because it provides the features we need and weve used it in the past. TastyPie is well known for its direct integration with Django Model objects (through its ModelResource class), however it also provides support for generic Python objects as well (through its Resource class).

To get you started on the concept of non-ORM data sources, TastyPie provides a tutorial here .

When we first built our API and its supporting ORM objects, we made several assumptions which later turned out to be false. We had to update our data model, splitting one API-supported ORM object into two, therefore making its endpoint obsolete. We didnt want to change the API endpoint itself, which would have created more work for our app developers. Instead, we decided to turn the same endpoint into a non-ORM resource to interface with our new data model.

Our original endpoint supported this ORM object:

class MobileAppUserToken(models.Model):
mobile_app_user = models.ForeignKey(MobileAppUser, blank=True, null=False)
platform = models.CharField(max_length=50, blank=True, choices=_get_app_choices())
token = models.CharField(max_length=255, blank=True, unique=True)

and looked like this:

class MobileAppUserTokenResource(ModelResource):
mobile_app_user = fields.ForeignKey(MobileAppUserResource, "mobile_app_user")

class Meta:
resource_name = 'mobileapp_token'
queryset = MobileAppUserToken.objects.all()
list_allowed_methods = ['post']
detail_allowed_methods = ['patch', 'delete']
fields = ['token', 'platform']
include_resource_uri = False
detail_uri_name = 'token'
authentication = Authentication()
authorization = Authorization()
validation = Validation()

def hydrate_platform(self, bundle):
bundle.data['platform'] = bundle.data['platform'].lower()
return bundle

Each instance of our mobile app installed on a users device is assigned a token, or registration ID. Apple Push Notification Service (APNS) and Google Cloud Messenger (GCM) use those tokens to send push notifications to users. Whenever a user installed our app, a token was generated and sent (along with the device platform; iOS or Android) to our database via our mobileapp_token endpoint.

We later decided to use the django-push-notifications library to send notifications. It splits Android and iOS devices up into two different models: APNSDevice and GCMDevice. Those models store tokens for their respective device types and each contain a different set of methods for the different APNS and GCM APIs. Now we needed two models to represent tokens, not one!

Our new endpoint supports these two ORM objects:

class DiveAPNSDevice(APNSDevice):
mobile_app_user = models.ForeignKey(MobileAppUser, blank=True, null=False)
objects = APNSDeviceManager()

class DiveGCMDevice(GCMDevice):
mobile_app_user = models.ForeignKey(MobileAppUser, blank=True, null=False)
objects = GCMDeviceManager()

Notice we subclassed django-push-notifications models to leverage our existing MobileAppUser model. The new API endpoint looks like this:

class TokenObject(object):
"""
A really basic, non-ORM object we can feed to TastyPie and use
as an intermediary between the API and our two device models
(DiveGCMDevice and DiveAPNSDevice)

These attributes are what the user would pass into the API:
token: A long alphanumeric string representing the device app registration token
platform: 'android' or 'ios'
mobile_app_user: A string pointing to the URI for our mobile_app_user API, e.g."[the endpoint]/someemail@blah.com/"
"""
def __init__(self, token=None, platform=None, mobile_app_user=None):
self.token = token
self.platform = platform
self.mobile_app_user = mobile_app_user

class MobileAppUserTokenResource(Resource):
"""
We were silly and created a single device model (MobileAppUserToken) when
we made our mobile app V1 and didn't know how push notifications worked.
The app devs then wrote their code to work with that first version API and model.
Since we didn't want to make them rewrite everything, we decided to keep the API
the same while updating our back end to support two device models, one for iOS and
one for Android (DiveAPNSDevice and DiveGCMDevice).
Since TastyPie doesn't support two ORM objects at one endpoint, we are using
their Resource class and a non-ORM object (TokenObject) as an intermediary to
our two device models.
Note that we have object_class instead of queryset in the Meta. The fields reflect what's
in our TokenObject above.
"""
token = fields.CharField(attribute='token')
platform = fields.CharField(attribute='platform')
mobile_app_user = fields.CharField(attribute='mobile_app_user')

class Meta:
resource_name = 'mobileapp_token'
object_class = TokenObject
detail_uri_name = 'token'
list_allowed_methods = ['post']
detail_allowed_methods = ['patch', 'delete']
include_resource_uri = False
authentication = Authentication()
authorization = Authorization()
validation = Validation()

def obj_get(self, bundle, **kwargs):
"""
Called by obj_update()
In Meta, we defined 'token' as the detail_uri_name, meaning that the URI:
"[the endpoint]/abc123/" will point to a token with the value "abc123".
To grab "abc123", we use kwargs['token'] in this function. kwargs['token'], which
is "abc123" from the URI in this case, is the existing token we want to get.
We can use that token to grab an existing device model via "registration_id". Then
we use that model's information to create a TokenObject with the fields, "token",
"platform", and "mobile_app_user". That TokenObject is now the bundle.obj.
"""
token = kwargs['token']
try:
dive_obj = DiveAPNSDevice.objects.get(registration_id=token)
platform = 'ios'
except DiveAPNSDevice.DoesNotExist:
dive_obj = DiveGCMDevice.objects.get(registration_id=token)
platform = 'android'

mobile_app_user = '[the endpoint]' + dive_obj.mobile_app_user.email + '/'
return TokenObject(
token=dive_obj.registration_id,
platform=platform,
mobile_app_user=mobile_app_user,
)

def obj_update(self, bundle, **kwargs):
"""
obj_get() is called just before this in a PATCH situation. It uses the
detail URI to get bundle.obj (read obj_get() docstring above for more info).
bundle.obj contains the existing token, platform, and mobile_app_user to be
updated.
bundle.data contains the arguments passed in to update the existing token.
bundle.data['token'] is the new 'token' that was passed in as an argument.
bundle.obj.token is the old token that is being updated (again, this comes
from get_object()).
If mobile_app_user is present, the token's user will be changed.
If token is present, the token's value itself will be changed.
Neither value is required, so this can be called with one or the other.
"""
mobile_app_user = bundle.data.get('mobile_app_user', None)
new_token = bundle.data.get('token', None)
platform = bundle.obj.platform
old_token = bundle.obj.token

if mobile_app_user or new_token:
if platform == 'ios':
dive_objs = DiveAPNSDevice.objects.filter(
registration_id=old_token,
)
elif platform == 'android':
dive_objs = DiveGCMDevice.objects.filter(
registration_id=old_token,
)
if new_token:
dive_objs.update(
registration_id=new_token,
)
if mobile_app_user:
dive_objs.update(
mobile_app_user=MobileAppUser.objects.get(
email=mobile_app_user.split('/')[-2]
),
)

bundle.obj = TokenObject(token=new_token, platform=platform, mobile_app_user=mobile_app_user)
return bundle
else:
raise ImmediateHttpResponse(
HttpBadRequest(
'400 Bad Request: must provide a token or mobile_app_user with which to update'
)
)

def obj_create(self, bundle, **kwargs):
"""
This creates a new Device model using arguments passed into a POST request.
bundle.data contains the arguments passed to the request. bundle.obj is a TokenObject
we create and pass back to TastyPie. TastyPie uses that to return a URI to the developers
where they can find the object in the future.
"""
token = bundle.data.get('token', None)
platform = bundle.data.get('platform', None)
mobile_app_user = bundle.data.get('mobile_app_user', None)

if platform and token and mobile_app_user:
if platform == 'ios':
DiveAPNSDevice.objects.create(
registration_id=token,
mobile_app_user=MobileAppUser.objects.get(
email=mobile_app_user.split('/')[-2]
),
)
elif platform == 'android':
DiveGCMDevice.objects.create(
registration_id=token,
mobile_app_user=MobileAppUser.objects.get(
email=mobile_app_user.split('/')[-2]),
)

bundle.obj = TokenObject(token=token, platform=platform, mobile_app_user=mobile_app_user)
return bundle
else:
raise ImmediateHttpResponse(
HttpBadRequest(
'400 Bad Request: token, mobile_app_user, and platform required to POST'
)
)

def obj_delete(self, bundle, **kwargs):
"""
If developers send a DELETE request to a detail URI like:
"[the endpoint]/abc123/", the device with that token (registration_id)
will be deleted.
kwargs['token'] is the "abc123" in the URI.
"""
token = kwargs['token']

try:
DiveAPNSDevice.objects.get(registration_id=token).delete()
except:
DiveGCMDevice.objects.get(registration_id=token).delete()

def rollback(self, bundles):
pass

Notice we split our mobile device model into two: one for iOS and one for Android. Our reason for doing so was to leverage the django-push-notifications library, but I digress.

The TastyPie tutorial on non-ORM data sources is a good starting point, but becomes confusing when mixed with Riak components. What part of their example is TastyPie and what part is Riak? Lets go over the Industry Dive code posted above to get a more in-depth look at whats going on (docstrings removed to prevent redundancy).

First we create our non-ORM object. This is what the API will use to represent incoming and outgoing data. When POSTing or PATCHing, we populate this object with incoming request data. When GETing we use our DiveGCMDevice and DiveAPNSDevice models to populate the object with data from our database.

class TokenObject(object):
def __init__(self, token=None, platform=None, mobile_app_user=None):
self.token = token
self.platform = platform
self.mobile_app_user = mobile_app_user

Its simple: a device token, platform name (android or ios) and an API endpoint to our mobile app user model.

Next, we initialize the API resource. Note were using Resource, not ModelResource.

class MobileAppUserTokenResource(Resource):
token = fields.CharField(attribute='token')
platform = fields.CharField(attribute='platform')
mobile_app_user = fields.CharField(attribute='mobile_app_user')

class Meta:
resource_name = 'mobileapp_token'
object_class = TokenObject
detail_uri_name = 'token'
list_allowed_methods = ['post']
detail_allowed_methods = ['patch', 'delete']
include_resource_uri = False
authentication = Authentication()
authorization = Authorization()
validation = Validation()

Note the use of object_class instead of queryset. We also decided to set our detail field to the device token. So the endpoint for a single TokenObject instance is [the endpoint]/[token]/.

We then override TastyPie Resources obj_get and obj_update methods (obj_update calls obj_get), which allows us to control what happens during PATCH requests.

def obj_get(self, bundle, **kwargs):
token = kwargs['token']

try:
dive_obj = DiveAPNSDevice.objects.get(registration_id=token)
platform = 'ios'
except DiveAPNSDevice.DoesNotExist:
dive_obj = DiveGCMDevice.objects.get(registration_id=token)
platform = 'android'

mobile_app_user = [the endpoint]/' + dive_obj.mobile_app_user.email + '/'
return TokenObject(
token=dive_obj.registration_id,
platform=platform,
mobile_app_user=mobile_app_user,
)

def obj_update(self, bundle, **kwargs):
mobile_app_user = bundle.data.get('mobile_app_user', None)
new_token = bundle.data.get('token', None)
platform = bundle.obj.platform
old_token = bundle.obj.token

if mobile_app_user or new_token:
if platform == 'ios':
dive_objs = DiveAPNSDevice.objects.filter(
registration_id=old_token,
)
elif platform == 'android':
dive_objs = DiveGCMDevice.objects.filter(
registration_id=old_token,
)
if new_token:
dive_objs.update(
registration_id=new_token,
)
if mobile_app_user:
dive_objs.update(
mobile_app_user=MobileAppUser.objects.get(
email=mobile_app_user.split('/')[-2]
),
)

bundle.obj =
TokenObject(token=new_token, platform=platform, mobile_app_user=mobile_app_user)
return bundle
else:
raise ImmediateHttpResponse(
HttpBadRequest(
'400 Bad Request: must provide a token or mobile_app_user with which to update'
)
)

Its important to understand the difference between bundle.obj and bundle.data. bundle.obj is a reference to our TokenObject (a relationship we specified in the class Meta using object_class). In obj_get, we grab the token from the API endpoint detail ([the endpoint]/[token]) using kwargs[token]. Since we dont know if this is an iOS or Android device, we use that token to query both device models in our database. Once we find a matching device model object, we use its information to instantiate and return a TokenObject, i.e. bundle.obj. Our new TokenObject and bundle.obj are the same thing here.

obj_update is called after obj_get and uses the newly instantiated TokenObject, or bundle.obj. It also retrieves new data passed into the PATCH request via bundle.data. If I want to know what an existing objects mobile_app_user is, I would use bundle.obj.mobile_app_user. If I want to know what new mobile_app_user Im updating the object to (which one was passed in via the PATCH request), I would use bundle.data[mobile_app_user]. Using this information, I now have the flexibility to determine which platform our incoming user is on, and update the correct ORM object accordingly.

We can also override the obj_create method for POST requests (no overriding of obj_get necessary to override obj_create).

def obj_create(self, bundle, **kwargs):
token = bundle.data.get('token', None)
platform = bundle.data.get('platform', None)
mobile_app_user = bundle.data.get('mobile_app_user', None)

if platform and token and mobile_app_user:
if platform == 'ios':
DiveAPNSDevice.objects.create(
registration_id=token,
mobile_app_user=MobileAppUser.objects.get(
email=mobile_app_user.split('/')[-2]
),
)
elif platform == 'android':
DiveGCMDevice.objects.create(
registration_id=token,
mobile_app_user=MobileAppUser.objects.get(
email=mobile_app_user.split('/')[-2]
),
)

bundle.obj = TokenObject(token=token, platform=platform, mobile_app_user=mobile_app_user)
return bundle
else:
raise ImmediateHttpResponse(
HttpBadRequest(
'400 Bad Request: token, mobile_app_user, and platform required to POST'
)
)

And obj_delete for a DELETE request:

def obj_delete(self, bundle, **kwargs):
token = kwargs['token']

try:
DiveAPNSDevice.objects.get(registration_id=token).delete()
except:
DiveGCMDevice.objects.get(registration_id=token).delete()

As you can see, the concepts are similar.

Hopefully this gives a more general and in-depth overview of how to override TastyPies Resource model to give you flexibility during those roll-with-the-punches type of moments!

PS: Similar overrides can be made on TastyPies ModelResource, except that the methods are called object_update, object_create, etc. instead of obj_update, obj_create, etc.

Topic
###