diff options
Diffstat (limited to 'src/rest')
| -rw-r--r-- | src/rest/modelresource.py | 52 | ||||
| -rw-r--r-- | src/rest/parsers.py | 6 | ||||
| -rw-r--r-- | src/rest/resource.py | 80 | ||||
| -rw-r--r-- | src/rest/status.py | 50 |
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 |
