aboutsummaryrefslogtreecommitdiffstats
path: root/src/rest
diff options
context:
space:
mode:
Diffstat (limited to 'src/rest')
-rw-r--r--src/rest/modelresource.py52
-rw-r--r--src/rest/parsers.py6
-rw-r--r--src/rest/resource.py80
-rw-r--r--src/rest/status.py50
4 files changed, 151 insertions, 37 deletions
diff --git a/src/rest/modelresource.py b/src/rest/modelresource.py
index 39358d9b..6719a9ed 100644
--- a/src/rest/modelresource.py
+++ b/src/rest/modelresource.py
@@ -12,10 +12,38 @@ import re
class ModelResource(Resource):
+ """A specialized type of Resource, for RESTful resources that map directly to a Django Model.
+ Useful things this provides:
+
+ 0. Default input validation based on ModelForms.
+ 1. Nice serialization of returned Models and QuerySets.
+ 2. A default set of create/read/update/delete operations."""
+
+ # The model attribute refers to the Django Model which this Resource maps to.
+ # (The Model's class, rather than an instance of the Model)
model = None
+
+ # By default the set of returned fields will be the set of:
+ #
+ # 0. All the fields on the model, excluding 'id'.
+ # 1. All the properties on the model.
+ # 2. The absolute_url of the model, if a get_absolute_url method exists for the model.
+ #
+ # If you wish to override this behaviour,
+ # you should explicitly set the fields attribute on your class.
fields = None
+
+ # By default the form used with be a ModelForm for self.model
+ # If you wish to override this behaviour or provide a sub-classed ModelForm
+ # you should explicitly set the form attribute on your class.
+ form = None
+
+ # By default the set of input fields will be the same as the set of output fields
+ # If you wish to override this behaviour you should explicitly set the
+ # form_fields attribute on your class.
form_fields = None
+
def get_bound_form(self, data=None, is_response=False):
"""Return a form that may be used in validation and/or rendering an html emitter"""
if self.form:
@@ -25,7 +53,7 @@ class ModelResource(Resource):
class NewModelForm(ModelForm):
class Meta:
model = self.model
- fields = self.form_fields if self.form_fields else None #self.fields
+ fields = self.form_fields if self.form_fields else None
if data and not is_response:
return NewModelForm(data)
@@ -36,7 +64,27 @@ class ModelResource(Resource):
else:
return None
-
+
+
+ def cleanup_request(self, data, form_instance):
+ """Override cleanup_request to drop read-only fields from the input prior to validation.
+ This ensures that we don't error out with 'non-existent field' when these fields are supplied,
+ and allows for a pragmatic approach to resources which include read-only elements.
+
+ I would actually like to be strict and verify the value of correctness of the values in these fields,
+ although that gets tricky as it involves validating at the point that we get the model instance.
+
+ See here for another example of this approach:
+ http://fedoraproject.org/wiki/Cloud_APIs_REST_Style_Guide
+ https://www.redhat.com/archives/rest-practices/2010-April/thread.html#00041"""
+ read_only_fields = set(self.fields) - set(self.form_instance.fields)
+ input_fields = set(data.keys())
+
+ clean_data = {}
+ for key in input_fields - read_only_fields:
+ clean_data[key] = data[key]
+
+ return super(ModelResource, self).cleanup_request(clean_data, form_instance)
def cleanup_response(self, data):
diff --git a/src/rest/parsers.py b/src/rest/parsers.py
index 73073243..ac449e49 100644
--- a/src/rest/parsers.py
+++ b/src/rest/parsers.py
@@ -1,4 +1,5 @@
import json
+from rest.status import ResourceException, Status
class BaseParser(object):
def __init__(self, resource):
@@ -10,7 +11,10 @@ class BaseParser(object):
class JSONParser(BaseParser):
def parse(self, input):
- return json.loads(input)
+ try:
+ return json.loads(input)
+ except ValueError, exc:
+ raise ResourceException(Status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)})
class XMLParser(BaseParser):
pass
diff --git a/src/rest/resource.py b/src/rest/resource.py
index e66cb357..b94854f5 100644
--- a/src/rest/resource.py
+++ b/src/rest/resource.py
@@ -1,39 +1,29 @@
-from django.http import HttpResponse
from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse
from django.core.handlers.wsgi import STATUS_CODE_TEXT
+from django.http import HttpResponse
from rest import emitters, parsers
+from rest.status import Status, ResourceException
from decimal import Decimal
import re
+# TODO: Authentication
# TODO: Display user login in top panel: http://stackoverflow.com/questions/806835/django-redirect-to-previous-page-after-login
-# TODO: Return basic object, not tuple
+# TODO: Return basic object, not tuple of status code, content, headers
# TODO: Take request, not headers
-# TODO: Remove self.blah munging (Add a ResponseContext object)
-# TODO: Erroring on non-existent fields
-# TODO: Standard exception classes and module for status codes
+# TODO: Standard exception classes
# TODO: Figure how out references and named urls need to work nicely
# TODO: POST on existing 404 URL, PUT on existing 404 URL
-# TODO: Authentication
+#
+# NEXT: Generic content form
+# NEXT: Remove self.blah munging (Add a ResponseContext object?)
+# NEXT: Caching cleverness
+# NEXT: Test non-existent fields on ModelResources
#
# FUTURE: Erroring on read-only fields
# Documentation, Release
-#
-STATUS_400_BAD_REQUEST = 400
-STATUS_405_METHOD_NOT_ALLOWED = 405
-STATUS_406_NOT_ACCEPTABLE = 406
-STATUS_415_UNSUPPORTED_MEDIA_TYPE = 415
-STATUS_500_INTERNAL_SERVER_ERROR = 500
-STATUS_501_NOT_IMPLEMENTED = 501
-
-
-class ResourceException(Exception):
- def __init__(self, status, content='', headers={}):
- self.status = status
- self.content = content
- self.headers = headers
class Resource(object):
@@ -110,13 +100,16 @@ class Resource(object):
def reverse(self, view, *args, **kwargs):
"""Return a fully qualified URI for a given view or resource.
- Use the Sites framework if possible, otherwise fallback to using the current request."""
+ Add the domain using the Sites framework if possible, otherwise fallback to using the current request."""
return self.add_domain(reverse(view, *args, **kwargs))
def add_domain(self, path):
"""Given a path, return an fully qualified URI.
Use the Sites framework if possible, otherwise fallback to using the domain from the current request."""
+
+ # Note that out-of-the-box the Sites framework uses the reserved domain 'example.com'
+ # See RFC 2606 - http://www.faqs.org/rfcs/rfc2606.html
try:
site = Site.objects.get_current()
if site.domain and site.domain != 'example.com':
@@ -150,7 +143,7 @@ class Resource(object):
def not_implemented(self, operation):
"""Return an HTTP 500 server error if an operation is called which has been allowed by
allowed_operations, but which has not been implemented."""
- raise ResourceException(STATUS_500_INTERNAL_SERVER_ERROR,
+ raise ResourceException(Status.HTTP_500_INTERNAL_SERVER_ERROR,
{'detail': '%s operation on this resource has not been implemented' % (operation, )})
@@ -172,18 +165,18 @@ class Resource(object):
# if anon_user and not anon_allowed_operations raise PermissionDenied
# return
+
def check_method_allowed(self, method):
"""Ensure the request method is acceptable for this resource."""
if not method in self.CALLMAP.keys():
- raise ResourceException(STATUS_501_NOT_IMPLEMENTED,
+ raise ResourceException(Status.HTTP_501_NOT_IMPLEMENTED,
{'detail': 'Unknown or unsupported method \'%s\'' % method})
if not self.CALLMAP[method] in self.allowed_operations:
- raise ResourceException(STATUS_405_METHOD_NOT_ALLOWED,
+ raise ResourceException(Status.HTTP_405_METHOD_NOT_ALLOWED,
{'detail': 'Method \'%s\' not allowed on this resource.' % method})
-
def get_bound_form(self, data=None, is_response=False):
"""Optionally return a Django Form instance, which may be used for validation
and/or rendered by an HTML/XHTML emitter.
@@ -207,16 +200,31 @@ class Resource(object):
By default this uses form validation to filter the basic input into the required types."""
if form_instance is None:
return data
-
- if not form_instance.is_valid():
- if not form_instance.errors:
+
+ # Default form validation does not check for additional invalid fields
+ non_existent_fields = []
+ for key in set(data.keys()) - set(form_instance.fields.keys()):
+ non_existent_fields.append(key)
+
+ if not form_instance.is_valid() or non_existent_fields:
+ if not form_instance.errors and not non_existent_fields:
+ # If no data was supplied the errors property will be None
details = 'No content was supplied'
+
else:
+ # Add standard field errors
details = dict((key, map(unicode, val)) for (key, val) in form_instance.errors.iteritems())
+
+ # Add any non-field errors
if form_instance.non_field_errors():
- details['_extra'] = self.form.non_field_errors()
+ details['errors'] = self.form.non_field_errors()
- raise ResourceException(STATUS_400_BAD_REQUEST, {'detail': details})
+ # Add any non-existent field errors
+ for key in non_existent_fields:
+ details[key] = ['This field does not exist']
+
+ # Bail. Note that we will still serialize this response with the appropriate content type
+ raise ResourceException(Status.HTTP_400_BAD_REQUEST, {'detail': details})
return form_instance.cleaned_data
@@ -241,7 +249,7 @@ class Resource(object):
try:
return self.parsers[content_type]
except KeyError:
- raise ResourceException(STATUS_415_UNSUPPORTED_MEDIA_TYPE,
+ raise ResourceException(Status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
{'detail': 'Unsupported media type \'%s\'' % content_type})
@@ -295,14 +303,13 @@ class Resource(object):
(accept_mimetype == mimetype)):
return (mimetype, emitter)
- raise ResourceException(STATUS_406_NOT_ACCEPTABLE,
+ raise ResourceException(Status.HTTP_406_NOT_ACCEPTABLE,
{'detail': 'Could not statisfy the client\'s accepted content type',
'accepted_types': [item[0] for item in self.emitters]})
def _handle_request(self, request, *args, **kwargs):
"""
-
Broadly this consists of the following procedure:
0. ensure the operation is permitted
@@ -347,9 +354,14 @@ class Resource(object):
except ResourceException, exc:
+ # On exceptions we still serialize the response appropriately
(self.resp_status, ret, self.resp_headers) = (exc.status, exc.content, exc.headers)
+
+ # Fall back to the default emitter if we failed to perform content negotiation
if emitter is None:
- mimetype, emitter = self.emitters[0]
+ mimetype, emitter = self.emitters[0]
+
+ # Provide an empty bound form if we do not have an existing form and if one is required
if self.form_instance is None and emitter.uses_forms:
self.form_instance = self.get_bound_form()
diff --git a/src/rest/status.py b/src/rest/status.py
new file mode 100644
index 00000000..d1b49d69
--- /dev/null
+++ b/src/rest/status.py
@@ -0,0 +1,50 @@
+
+class Status(object):
+ """Descriptive HTTP status codes, for code readability."""
+ HTTP_200_OK = 200
+ HTTP_201_CREATED = 201
+ HTTP_202_ACCEPTED = 202
+ HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203
+ HTTP_204_NO_CONTENT = 204
+ HTTP_205_RESET_CONTENT = 205
+ HTTP_206_PARTIAL_CONTENT = 206
+ HTTP_400_BAD_REQUEST = 400
+ HTTP_401_UNAUTHORIZED = 401
+ HTTP_402_PAYMENT_REQUIRED = 402
+ HTTP_403_FORBIDDEN = 403
+ HTTP_404_NOT_FOUND = 404
+ HTTP_405_METHOD_NOT_ALLOWED = 405
+ HTTP_406_NOT_ACCEPTABLE = 406
+ HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407
+ HTTP_408_REQUEST_TIMEOUT = 408
+ HTTP_409_CONFLICT = 409
+ HTTP_410_GONE = 410
+ HTTP_411_LENGTH_REQUIRED = 411
+ HTTP_412_PRECONDITION_FAILED = 412
+ HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413
+ HTTP_414_REQUEST_URI_TOO_LONG = 414
+ HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415
+ HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416
+ HTTP_417_EXPECTATION_FAILED = 417
+ HTTP_100_CONTINUE = 100
+ HTTP_101_SWITCHING_PROTOCOLS = 101
+ HTTP_300_MULTIPLE_CHOICES = 300
+ HTTP_301_MOVED_PERMANENTLY = 301
+ HTTP_302_FOUND = 302
+ HTTP_303_SEE_OTHER = 303
+ HTTP_304_NOT_MODIFIED = 304
+ HTTP_305_USE_PROXY = 305
+ HTTP_306_RESERVED = 306
+ HTTP_307_TEMPORARY_REDIRECT = 307
+ HTTP_500_INTERNAL_SERVER_ERROR = 500
+ HTTP_501_NOT_IMPLEMENTED = 501
+ HTTP_502_BAD_GATEWAY = 502
+ HTTP_503_SERVICE_UNAVAILABLE = 503
+ HTTP_504_GATEWAY_TIMEOUT = 504
+ HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505
+
+class ResourceException(Exception):
+ def __init__(self, status, content='', headers={}):
+ self.status = status
+ self.content = content
+ self.headers = headers