Sorting out resources. Doing some crazy magic automatic url resolving stuff. Yum.

This commit is contained in:
Tom Christie 2011-05-13 17:19:12 +01:00
parent 8f6bcac7f3
commit 325e63a3a7
5 changed files with 200 additions and 348 deletions

View File

@ -25,6 +25,8 @@ __all__ = (
'ResponseMixin',
'AuthMixin',
'ResourceMixin',
#
'InstanceMixin',
# Model behavior mixins
'ReadModelMixin',
'CreateModelMixin',
@ -137,7 +139,7 @@ class RequestMixin(object):
content_length = 0
# TODO: Add 1.3's LimitedStream to compat and use that.
# Currently only supports parsing request body as a stream with 1.3
# NOTE: Currently only supports parsing request body as a stream with 1.3
if content_length == 0:
return None
elif hasattr(request, 'read'):
@ -379,8 +381,8 @@ class AuthMixin(object):
if not hasattr(self, '_user'):
self._user = self._authenticate()
return self._user
def _authenticate(self):
"""
Attempt to authenticate the request using each authentication class in turn.
@ -405,26 +407,71 @@ class AuthMixin(object):
permission.check_permission(user)
##########
class InstanceMixin(object):
"""
Mixin class that is used to identify a view class as being the canonical identifier
for the resources it is mapped too.
"""
@classmethod
def as_view(cls, **initkwargs):
"""
Store the callable object on the resource class that has been associated with this view.
"""
view = super(InstanceMixin, cls).as_view(**initkwargs)
if 'resource' in initkwargs:
# We do a little dance when we store the view callable...
# we need to store it wrapped in a 1-tuple, so that inspect will treat it
# as a function when we later look it up (rather than turning it into a method).
# This makes sure our URL reversing works ok.
initkwargs['resource'].view_callable = (view,)
return view
########## Resource Mixin ##########
class ResourceMixin(object):
"""
Provides request validation and response filtering behavior.
"""
"""
Should be a class as described in the ``resources`` module.
The ``resource`` is an object that maps a view onto it's representation on the server.
It provides validation on the content of incoming requests,
and filters the object representation into a serializable object for the response.
"""
resource = None
@property
def CONTENT(self):
if not hasattr(self, '_content'):
self._content = self._get_content()
self._content = self.validate_request(self.DATA, self.FILES)
return self._content
def _get_content(self):
def validate_request(self, data, files):
"""
Given the request data return the cleaned, validated content.
Typically raises a ErrorResponse with status code 400 (Bad Request) on failure.
"""
resource = self.resource(self)
return resource.validate(self.DATA, self.FILES)
return resource.validate_request(data, files)
def filter_response(self, obj):
"""
Given the response content, filter it into a serializable object.
"""
resource = self.resource(self)
return resource.filter_response(obj)
def get_bound_form(self, content=None):
resource = self.resource(self)
return resource.get_bound_form(content)
def object_to_data(self, obj):
resource = self.resource(self)
return resource.object_to_data(obj)
########## Model Mixins ##########

View File

@ -1,3 +1,5 @@
from django import forms
from django.core.urlresolvers import reverse, get_urlconf, get_resolver, NoReverseMatch
from django.db import models
from django.db.models.query import QuerySet
from django.db.models.fields.related import RelatedField
@ -9,10 +11,15 @@ import re
def _model_to_dict(instance, fields=None, exclude=None):
def _model_to_dict(instance, resource=None):
"""
This is a clone of Django's ``django.forms.model_to_dict`` except that it
doesn't coerce related objects into primary keys.
Given a model instance, return a ``dict`` representing the model.
The implementation is similar to Django's ``django.forms.model_to_dict``, except:
* It doesn't coerce related objects into primary keys.
* It doesn't drop ``editable=False`` fields.
* It also supports attribute or method fields on the instance or resource.
"""
opts = instance._meta
data = {}
@ -20,10 +27,19 @@ def _model_to_dict(instance, fields=None, exclude=None):
#print [rel.name for rel in opts.get_all_related_objects()]
#related = [rel.get_accessor_name() for rel in opts.get_all_related_objects()]
#print [getattr(instance, rel) for rel in related]
#if resource.fields:
# fields = resource.fields
#else:
# fields = set(opts.fields + opts.many_to_many)
fields = resource.fields
include = resource.include
exclude = resource.exclude
extra_fields = fields and list(resource.fields) or []
# Model fields
for f in opts.fields + opts.many_to_many:
#if not f.editable:
# continue
if fields and not f.name in fields:
continue
if exclude and f.name in exclude:
@ -32,87 +48,84 @@ def _model_to_dict(instance, fields=None, exclude=None):
data[f.name] = getattr(instance, f.name)
else:
data[f.name] = f.value_from_object(instance)
#print fields - (opts.fields + opts.many_to_many)
#for related in [rel.get_accessor_name() for rel in opts.get_all_related_objects()]:
# if fields and not related in fields:
# continue
# if exclude and related in exclude:
# continue
# data[related] = getattr(instance, related)
if extra_fields and f.name in extra_fields:
extra_fields.remove(f.name)
# Method fields
for fname in extra_fields:
if hasattr(resource, fname):
# check the resource first, to allow it to override fields
obj = getattr(resource, fname)
# if it's a method like foo(self, instance), then call it
if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 2:
obj = obj(instance)
elif hasattr(instance, fname):
# now check the object instance
obj = getattr(instance, fname)
else:
continue
# TODO: It would be nicer if this didn't recurse here.
# Let's keep _model_to_dict flat, and _object_to_data recursive.
data[fname] = _object_to_data(obj)
return data
def _object_to_data(obj):
def _object_to_data(obj, resource=None):
"""
Convert an object into a serializable representation.
"""
if isinstance(obj, dict):
# dictionaries
return dict([ (key, _object_to_data(val)) for key, val in obj.iteritems() ])
# TODO: apply same _model_to_dict logic fields/exclude here
return dict([ (key, _object_to_data(val)) for key, val in obj.iteritems() ])
if isinstance(obj, (tuple, list, set, QuerySet)):
# basic iterables
return [_object_to_data(item) for item in obj]
return [_object_to_data(item, resource) for item in obj]
if isinstance(obj, models.Manager):
# Manager objects
return [_object_to_data(item) for item in obj.all()]
return [_object_to_data(item, resource) for item in obj.all()]
if isinstance(obj, models.Model):
# Model instances
return _object_to_data(_model_to_dict(obj))
return _object_to_data(_model_to_dict(obj, resource))
if isinstance(obj, decimal.Decimal):
# Decimals (force to string representation)
return str(obj)
if inspect.isfunction(obj) and not inspect.getargspec(obj)[0]:
# function with no args
return _object_to_data(obj())
if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 1:
# method with only a 'self' args
return _object_to_data(obj())
return _object_to_data(obj(), resource)
if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) <= 1:
# bound method
return _object_to_data(obj(), resource)
# fallback
return smart_unicode(obj, strings_only=True)
def _form_to_data(form):
"""
Returns a dict containing the data in a form instance.
This code is pretty much a clone of the ``Form.as_p()`` ``Form.as_ul``
and ``Form.as_table()`` methods, except that it returns data suitable
for arbitrary serialization, rather than rendering the result directly
into html.
"""
ret = {}
for name, field in form.fields.items():
if not form.is_bound:
data = form.initial.get(name, field.initial)
if callable(data):
data = data()
else:
if isinstance(field, FileField) and form.data is None:
data = form.initial.get(name, field.initial)
else:
data = field.widget.value_from_datadict(form.data, form.files, name)
ret[name] = field.prepare_value(data)
return ret
class BaseResource(object):
"""Base class for all Resource classes, which simply defines the interface they provide."""
"""
Base class for all Resource classes, which simply defines the interface they provide.
"""
fields = None
include = None
exclude = None
def __init__(self, view):
self.view = view
def validate(self, data, files):
"""Given some content as input return some cleaned, validated content.
def validate_request(self, data, files):
"""
Given the request data return the cleaned, validated content.
Typically raises a ErrorResponse with status code 400 (Bad Request) on failure.
Must be overridden to be implemented."""
"""
return data
def object_to_data(self, obj):
return _object_to_data(obj)
def filter_response(self, obj):
"""
Given the response content, filter it into a serializable object.
"""
return _object_to_data(obj, self)
class Resource(BaseResource):
@ -135,247 +148,18 @@ class Resource(BaseResource):
# you should explicitly set the fields attribute on your class.
fields = None
# TODO: Replace this with new Serializer code based on Forms API.
def object_to_data(self, obj):
"""
A (horrible) munging of Piston's pre-serialization. Returns a dict.
"""
return _object_to_data(obj)
def _any(thing, fields=()):
"""
Dispatch, all types are routed through here.
"""
ret = None
if isinstance(thing, QuerySet):
ret = _qs(thing, fields=fields)
elif isinstance(thing, (tuple, list)):
ret = _list(thing)
elif isinstance(thing, dict):
ret = _dict(thing)
elif isinstance(thing, int):
ret = thing
elif isinstance(thing, bool):
ret = thing
elif isinstance(thing, type(None)):
ret = thing
elif isinstance(thing, decimal.Decimal):
ret = str(thing)
elif isinstance(thing, models.Model):
ret = _model(thing, fields=fields)
#elif isinstance(thing, HttpResponse): TRC
# raise HttpStatusCode(thing)
elif inspect.isfunction(thing):
if not inspect.getargspec(thing)[0]:
ret = _any(thing())
elif hasattr(thing, '__rendertable__'):
f = thing.__rendertable__
if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1:
ret = _any(f())
else:
ret = unicode(thing) # TRC
return ret
def _fk(data, field):
"""
Foreign keys.
"""
return _any(getattr(data, field.name))
def _related(data, fields=()):
"""
Foreign keys.
"""
return [ _model(m, fields) for m in data.iterator() ]
def _m2m(data, field, fields=()):
"""
Many to many (re-route to `_model`.)
"""
return [ _model(m, fields) for m in getattr(data, field.name).iterator() ]
def _method_fields(data, fields):
if not data:
return { }
has = dir(data)
ret = dict()
for field in fields:
if field in has:
ret[field] = getattr(data, field)
return ret
def _model(data, fields=()):
"""
Models. Will respect the `fields` and/or
`exclude` on the handler (see `typemapper`.)
"""
ret = { }
#handler = self.in_typemapper(type(data), self.anonymous) # TRC
handler = None # TRC
get_absolute_url = False
if fields:
v = lambda f: getattr(data, f.attname)
get_fields = set(fields)
if 'absolute_url' in get_fields: # MOVED (TRC)
get_absolute_url = True
met_fields = _method_fields(handler, get_fields) # TRC
for f in data._meta.local_fields:
if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]):
if not f.rel:
if f.attname in get_fields:
ret[f.attname] = _any(v(f))
get_fields.remove(f.attname)
else:
if f.attname[:-3] in get_fields:
ret[f.name] = _fk(data, f)
get_fields.remove(f.name)
for mf in data._meta.many_to_many:
if mf.serialize and mf.attname not in met_fields:
if mf.attname in get_fields:
ret[mf.name] = _m2m(data, mf)
get_fields.remove(mf.name)
# try to get the remainder of fields
for maybe_field in get_fields:
if isinstance(maybe_field, (list, tuple)):
model, fields = maybe_field
inst = getattr(data, model, None)
if inst:
if hasattr(inst, 'all'):
ret[model] = _related(inst, fields)
elif callable(inst):
if len(inspect.getargspec(inst)[0]) == 1:
ret[model] = _any(inst(), fields)
else:
ret[model] = _model(inst, fields)
elif maybe_field in met_fields:
# Overriding normal field which has a "resource method"
# so you can alter the contents of certain fields without
# using different names.
ret[maybe_field] = _any(met_fields[maybe_field](data))
else:
maybe = getattr(data, maybe_field, None)
if maybe:
if callable(maybe):
if len(inspect.getargspec(maybe)[0]) == 1:
ret[maybe_field] = _any(maybe())
else:
ret[maybe_field] = _any(maybe)
else:
pass # TRC
#handler_f = getattr(handler or self.handler, maybe_field, None)
#
#if handler_f:
# ret[maybe_field] = _any(handler_f(data))
else:
# Add absolute_url if it exists
get_absolute_url = True
# Add all the fields
for f in data._meta.fields:
if f.attname != 'id':
ret[f.attname] = _any(getattr(data, f.attname))
# Add all the propertiess
klass = data.__class__
for attr in dir(klass):
if not attr.startswith('_') and not attr in ('pk','id') and isinstance(getattr(klass, attr, None), property):
#if attr.endswith('_url') or attr.endswith('_uri'):
# ret[attr] = self.make_absolute(_any(getattr(data, attr)))
#else:
ret[attr] = _any(getattr(data, attr))
#fields = dir(data.__class__) + ret.keys()
#add_ons = [k for k in dir(data) if k not in fields and not k.startswith('_')]
#print add_ons
###print dir(data.__class__)
#from django.db.models import Model
#model_fields = dir(Model)
#for attr in dir(data):
## #if attr.startswith('_'):
## # continue
# if (attr in fields) and not (attr in model_fields) and not attr.startswith('_'):
# print attr, type(getattr(data, attr, None)), attr in fields, attr in model_fields
#for k in add_ons:
# ret[k] = _any(getattr(data, k))
# TRC
# resouce uri
#if self.in_typemapper(type(data), self.anonymous):
# handler = self.in_typemapper(type(data), self.anonymous)
# if hasattr(handler, 'resource_uri'):
# url_id, fields = handler.resource_uri()
# ret['resource_uri'] = permalink( lambda: (url_id,
# (getattr(data, f) for f in fields) ) )()
# TRC
#if hasattr(data, 'get_api_url') and 'resource_uri' not in ret:
# try: ret['resource_uri'] = data.get_api_url()
# except: pass
# absolute uri
if hasattr(data, 'get_absolute_url') and get_absolute_url:
try: ret['absolute_url'] = data.get_absolute_url()
except: pass
#for key, val in ret.items():
# if key.endswith('_url') or key.endswith('_uri'):
# ret[key] = self.add_domain(val)
return ret
def _qs(data, fields=()):
"""
Querysets.
"""
return [ _any(v, fields) for v in data ]
def _list(data):
"""
Lists.
"""
return [ _any(v) for v in data ]
def _dict(data):
"""
Dictionaries.
"""
return dict([ (k, _any(v)) for k, v in data.iteritems() ])
# Kickstart the seralizin'.
return _any(obj, self.fields)
class FormResource(Resource):
"""Validator class that uses forms for validation.
"""
Resource class that uses forms for validation.
Also provides a get_bound_form() method which may be used by some renderers.
The view class should provide `.form` attribute which specifies the form classmethod
to be used for validation.
On calling validate() this validator may set a `.bound_form_instance` attribute on the
view, which may be used by some renderers."""
view, which may be used by some renderers.
"""
form = None
def validate(self, data, files):
def validate_request(self, data, files):
"""
Given some content as input return some cleaned, validated content.
Raises a ErrorResponse with status code 400 (Bad Request) on failure.
@ -434,10 +218,12 @@ class FormResource(Resource):
detail[u'errors'] = bound_form.non_field_errors()
# Add standard field errors
field_errors = dict((key, map(unicode, val))
field_errors = dict(
(key, map(unicode, val))
for (key, val)
in bound_form.errors.iteritems()
if not key.startswith('__'))
if not key.startswith('__')
)
# Add any unknown field errors
for key in unknown_fields:
@ -451,22 +237,24 @@ class FormResource(Resource):
def get_bound_form(self, data=None, files=None):
"""Given some content return a Django form bound to that content.
If form validation is turned off (form class attribute is None) then returns None."""
form_cls = getattr(self, 'form', None)
if not form_cls:
"""
Given some content return a Django form bound to that content.
If form validation is turned off (form class attribute is None) then returns None.
"""
if not self.form:
return None
if data is not None:
return form_cls(data, files)
return self.form(data, files)
return form_cls()
return self.form()
class ModelResource(FormResource):
"""Validator class that uses forms for validation and otherwise falls back to a model form if no form is set.
Also provides a get_bound_form() method which may be used by some renderers."""
"""
Resource class that uses forms for validation and otherwise falls back to a model form if no form is set.
Also provides a get_bound_form() method which may be used by some renderers.
"""
"""The form class that should be used for validation, or None to use model form validation."""
form = None
@ -477,16 +265,16 @@ class ModelResource(FormResource):
"""The list of fields we expect to receive as input. Fields in this list will may be received with
raising non-existent field errors, even if they do not exist as fields on the ModelForm.
Setting the fields class attribute causes the exclude_fields class attribute to be disregarded."""
Setting the fields class attribute causes the exclude class attribute to be disregarded."""
fields = None
"""The list of fields to exclude from the Model. This is only used if the fields class attribute is not set."""
exclude_fields = ('id', 'pk')
exclude = ('id', 'pk')
# TODO: test the different validation here to allow for get get_absolute_url to be supplied on input and not bork out
# TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.)
def validate(self, data, files):
def validate_request(self, data, files):
"""
Given some content as input return some cleaned, validated content.
Raises a ErrorResponse with status code 400 (Bad Request) on failure.
@ -503,66 +291,80 @@ class ModelResource(FormResource):
return self._validate(data, files, allowed_extra_fields=self._property_fields_set)
def get_bound_form(self, data=None, files=None):
def get_bound_form(self, content=None):
"""Given some content return a Django form bound to that content.
If the form class attribute has been explicitly set then use that class to create a Form,
otherwise if model is set use that class to create a ModelForm, otherwise return None."""
form_cls = getattr(self, 'form', None)
model_cls = getattr(self, 'model', None)
if form_cls:
if self.form:
# Use explict Form
return super(ModelFormValidator, self).get_bound_form(data, files)
elif model_cls:
elif self.model:
# Fall back to ModelForm which we create on the fly
class OnTheFlyModelForm(forms.ModelForm):
class Meta:
model = model_cls
model = self.model
#fields = tuple(self._model_fields_set)
# Instantiate the ModelForm as appropriate
if content and isinstance(content, models.Model):
# Bound to an existing model instance
return OnTheFlyModelForm(instance=content)
elif not data is None:
return OnTheFlyModelForm(data, files)
elif content is not None:
return OnTheFlyModelForm(content)
return OnTheFlyModelForm()
# Both form and model not set? Okay bruv, whatevs...
return None
def url(self, instance):
"""
Attempts to reverse resolve the url of the given model instance for this resource.
"""
# dis does teh magicks...
urlconf = get_urlconf()
resolver = get_resolver(urlconf)
possibilities = resolver.reverse_dict.getlist(self.view_callable[0])
for tuple_item in possibilities:
possibility = tuple_item[0]
# pattern = tuple_item[1]
# Note: defaults = tuple_item[2] for django >= 1.3
for result, params in possibility:
instance_attrs = dict([ (param, getattr(instance, param)) for param in params if hasattr(instance, param) ])
try:
return reverse(self.view_callable[0], kwargs=instance_attrs)
except NoReverseMatch:
pass
raise NoReverseMatch
@property
def _model_fields_set(self):
"""Return a set containing the names of validated fields on the model."""
resource = self.view.resource
model = getattr(resource, 'model', None)
fields = getattr(resource, 'fields', self.fields)
exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields)
model_fields = set(field.name for field in model._meta.fields)
"""
Return a set containing the names of validated fields on the model.
"""
model_fields = set(field.name for field in self.model._meta.fields)
if fields:
return model_fields & set(as_tuple(fields))
return model_fields & set(as_tuple(self.fields))
return model_fields - set(as_tuple(exclude_fields))
return model_fields - set(as_tuple(self.exclude))
@property
def _property_fields_set(self):
"""Returns a set containing the names of validated properties on the model."""
resource = self.view.resource
model = getattr(resource, 'model', None)
fields = getattr(resource, 'fields', self.fields)
exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields)
property_fields = set(attr for attr in dir(model) if
isinstance(getattr(model, attr, None), property)
"""
Returns a set containing the names of validated properties on the model.
"""
property_fields = set(attr for attr in dir(self.model) if
isinstance(getattr(self.model, attr, None), property)
and not attr.startswith('_'))
if fields:
return property_fields & set(as_tuple(fields))
return property_fields & set(as_tuple(self.fields))
return property_fields - set(as_tuple(exclude_fields))
return property_fields - set(as_tuple(self.exclude))

View File

@ -11,7 +11,7 @@ class TestObjectToData(TestCase):
def test_decimal(self):
"""Decimals need to be converted to a string representation."""
self.assertEquals(_object_to_data(decimal.Decimal('1.5')), '1.5')
def test_function(self):
"""Functions with no arguments should be called."""
def foo():

View File

@ -43,6 +43,7 @@ def add_media_type_param(media_type, key, val):
media_type.params[key] = val
return str(media_type)
def get_media_type_params(media_type):
"""
Return a dictionary of the parameters on the given media type.

View File

@ -18,8 +18,10 @@ __all__ = (
class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View):
"""Handles incoming requests and maps them to REST operations.
Performs request deserialization, response serialization, authentication and input validation."""
"""
Handles incoming requests and maps them to REST operations.
Performs request deserialization, response serialization, authentication and input validation.
"""
# Use the base resource by default
resource = resources.Resource
@ -77,8 +79,8 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View):
prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host())
set_script_prefix(prefix)
try:
# Authenticate and check request is has the relevant permissions
try:
# Authenticate and check request has the relevant permissions
self._check_permissions()
# Get the appropriate handler method
@ -98,7 +100,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View):
response = Response(status.HTTP_204_NO_CONTENT)
# Pre-serialize filtering (eg filter complex objects into natively serializable types)
response.cleaned_content = self.object_to_data(response.raw_content)
response.cleaned_content = self.filter_response(response.raw_content)
except ErrorResponse, exc:
response = exc.response
@ -118,7 +120,7 @@ class ModelView(BaseView):
"""A RESTful view that maps to a model in the database."""
resource = resources.ModelResource
class InstanceModelView(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView):
class InstanceModelView(InstanceMixin, ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView):
"""A view which provides default operations for read/update/delete against a model instance."""
pass