From 764fbe335fbd8dab2b9a097a008bd80bf6582f89 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 13 Jan 2011 17:38:40 +0000 Subject: [PATCH] Various cleanup --- src/rest/emitters.py | 9 --- src/rest/resource.py | 102 +++++++++++++++++++++----------- src/rest/templates/emitter.html | 21 +++---- src/rest/templates/emitter.txt | 9 ++- src/testapp/views.py | 30 ++++++---- 5 files changed, 101 insertions(+), 70 deletions(-) diff --git a/src/rest/emitters.py b/src/rest/emitters.py index dcbaf7b7d..b911f31c1 100644 --- a/src/rest/emitters.py +++ b/src/rest/emitters.py @@ -1,5 +1,4 @@ from django.template import RequestContext, loader -from django.core.handlers.wsgi import STATUS_CODE_TEXT import json from utils import dict2xml @@ -22,14 +21,6 @@ class TemplatedEmitter(BaseEmitter): template = loader.get_template(self.template) context = RequestContext(self.resource.request, { 'content': content, - 'status': self.resource.resp_status, - 'reason': STATUS_CODE_TEXT.get(self.resource.resp_status, ''), - 'headers': self.resource.resp_headers, - 'resource_name': self.resource.__class__.__name__, - 'resource_doc': self.resource.__doc__, - 'create_form': self.resource.form, - 'update_form': self.resource.form, - 'request': self.resource.request, 'resource': self.resource, }) diff --git a/src/rest/resource.py b/src/rest/resource.py index 15ccce48b..6ea19246a 100644 --- a/src/rest/resource.py +++ b/src/rest/resource.py @@ -1,7 +1,9 @@ from django.http import HttpResponse from django.core.urlresolvers import reverse +from django.core.handlers.wsgi import STATUS_CODE_TEXT from rest import emitters, parsers from decimal import Decimal +import re # STATUS_400_BAD_REQUEST = 400 @@ -22,6 +24,10 @@ class ResourceException(Exception): class Resource(object): # List of RESTful operations which may be performed on this resource. allowed_operations = ('read',) + anon_allowed_operations = () + + # Optional form for input validation and presentation of HTML formatted responses. + form = None # List of content-types the resource can respond with, ordered by preference emitters = ( ('application/json', emitters.JSONEmitter), @@ -36,9 +42,6 @@ class Resource(object): 'application/x-www-form-urlencoded': parsers.FormParser, 'multipart/form-data': parsers.FormParser } - # Optional form for input validation and presentation of HTML formatted responses. - form = None - # Map standard HTTP methods to RESTful operations CALLMAP = { 'GET': 'read', 'POST': 'create', 'PUT': 'update', 'DELETE': 'delete' } @@ -57,20 +60,34 @@ class Resource(object): """Make the class callable so it can be used as a Django view.""" self = object.__new__(cls) self.__init__() - self.request = request - try: - return self._handle_request(request, *args, **kwargs) - except: - import traceback - traceback.print_exc() - raise - + return self._handle_request(request, *args, **kwargs) def __init__(self): pass + def name(self): + """Provide a name for the resource. + By default this is the class name, with 'CamelCaseNames' converted to 'Camel Case Names', + although this behaviour may be overridden.""" + class_name = self.__class__.__name__ + return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', class_name).strip() + + + def description(self): + """Provide a description for the resource. + By default this is the class's docstring, + although this behaviour may be overridden.""" + return "%s" % self.__doc__ + + + def resp_status_text(self): + """Return reason text corrosponding to our HTTP response status code. + Provided for convienience.""" + return STATUS_CODE_TEXT.get(self.resp_status, '') + + def reverse(self, view, *args, **kwargs): """Return a fully qualified URI for a given view or resource, using the current request as the base URI. TODO: Add SITEMAP option. @@ -125,8 +142,14 @@ class Resource(object): return method + def authenticate(self): + """...""" + # user = ... + # if anon_user and not anon_allowed_operations raise PermissionDenied + # return + def check_method_allowed(self, method): - """Ensure the request method is acceptable fot this resource.""" + """Ensure the request method is acceptable for this resource.""" if not method in self.CALLMAP.keys(): raise ResourceException(STATUS_501_NOT_IMPLEMENTED, {'detail': 'Unknown or unsupported method \'%s\'' % method}) @@ -137,13 +160,12 @@ class Resource(object): - def determine_form(self, data=None, is_response=False): + 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. If data is not None the form will be bound to data. is_response indicates if data should be - treated as the input data (bind to client input) or the response data (bind to an existing object). - """ + treated as the input data (bind to client input) or the response data (bind to an existing object).""" if self.form: if data: return self.form(data) @@ -156,15 +178,25 @@ class Resource(object): """Perform any resource-specific data deserialization and/or validation after the initial HTTP content-type deserialization has taken place. + Returns a tuple containing the cleaned up data, and optionally a form bound to that data. + By default this uses form validation to filter the basic input into the required types.""" if self.form is None: - return data + return (data, None) + + form_instance = self.get_bound_form(data) + + if not form_instance.is_valid(): + if not form_instance.errors: + details = 'No content was supplied' + else: + details = dict((key, map(unicode, val)) for (key, val) in form_instance.errors.iteritems()) + if form_instance.non_field_errors(): + details['_extra'] = self.form.non_field_errors() - if not self.form.is_valid(): - details = dict((key, map(unicode, val)) for (key, val) in self.form.errors.iteritems()) raise ResourceException(STATUS_400_BAD_REQUEST, {'detail': details}) - return self.form.cleaned_data + return (form_instance.cleaned_data, form_instance) def cleanup_response(self, data): @@ -188,7 +220,7 @@ class Resource(object): return self.parsers[content_type] except KeyError: raise ResourceException(STATUS_415_UNSUPPORTED_MEDIA_TYPE, - {'detail': 'Unsupported content type \'%s\'' % content_type}) + {'detail': 'Unsupported media type \'%s\'' % content_type}) def determine_emitter(self, request): @@ -253,13 +285,13 @@ class Resource(object): 5. serialize response data into response content, using standard HTTP content negotiation """ emitter = None + method = self.determine_method(request) # We make these attributes to allow for a certain amount of munging, # eg The HTML emitter needs to render this information - self.method = self.determine_method(request) - self.form = None + self.request = request + self.form_instance = None self.resp_status = None - self.resp_content = None self.resp_headers = {} try: @@ -267,32 +299,31 @@ class Resource(object): mimetype, emitter = self.determine_emitter(request) # Ensure the requested operation is permitted on this resource - self.check_method_allowed(self.method) + self.check_method_allowed(method) # Get the appropriate create/read/update/delete function - func = getattr(self, self.CALLMAP.get(self.method, '')) + func = getattr(self, self.CALLMAP.get(method, '')) # Either generate the response data, deserializing and validating any request data - if self.method in ('PUT', 'POST'): + if method in ('PUT', 'POST'): parser = self.determine_parser(request) data = parser(self).parse(request.raw_post_data) - self.form = self.determine_form(data) - data = self.cleanup_request(data) + (data, self.form_instance) = self.cleanup_request(data) (self.resp_status, ret, self.resp_headers) = func(data, request.META, *args, **kwargs) else: (self.resp_status, ret, self.resp_headers) = func(request.META, *args, **kwargs) - self.form = self.determine_form(ret, is_response=True) + self.form_instance = self.get_bound_form(ret, is_response=True) except ResourceException, exc: (self.resp_status, ret, self.resp_headers) = (exc.status, exc.content, exc.headers) if emitter is None: mimetype, emitter = self.emitters[0] - if self.form is None: - self.form = self.determine_form() + if self.form_instance is None: + self.form_instance = self.get_bound_form() + - # Always add the allow header self.resp_headers['Allow'] = ', '.join([self.REVERSE_CALLMAP[operation] for operation in self.allowed_operations]) @@ -315,17 +346,16 @@ from django.db.models.query import QuerySet from django.db.models import Model import decimal import inspect -import re class ModelResource(Resource): model = None fields = None form_fields = None - def determine_form(self, data=None, is_response=False): + 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: - return super(self.__class__, self).determine_form(data, is_response=is_response) + return super(self.__class__, self).get_bound_form(data, is_response=is_response) elif self.model: class NewModelForm(ModelForm): @@ -640,7 +670,7 @@ class ModelResource(Resource): class QueryModelResource(ModelResource): allowed_methods = ('read',) - def determine_form(self, data=None, is_response=False): + def get_bound_form(self, data=None, is_response=False): return None def read(self, headers={}, *args, **kwargs): diff --git a/src/rest/templates/emitter.html b/src/rest/templates/emitter.html index ddc91fbf9..056c52c53 100644 --- a/src/rest/templates/emitter.html +++ b/src/rest/templates/emitter.html @@ -7,26 +7,27 @@ pre {border: 1px solid black; padding: 1em; background: #ffd} div.action {padding: 0.5em 1em; margin-bottom: 0.5em; background: #ddf} + API - {{ resource.name }} -

{{ resource_name }}

-

{{ resource_doc }}

-
{{ status }} {{ reason }}{% autoescape off %}
-{% for key, val in headers.items %}{{ key }}: {{ val|urlize_quoted_links }}
+    

{{ resource.name }}

+

{{ resource.description }}

+
{{ resource.resp_status }} {{ resource.resp_status_text }}{% autoescape off %}
+{% for key, val in resource.resp_headers.items %}{{ key }}: {{ val|urlize_quoted_links }}
 {% endfor %}
 {{ content|urlize_quoted_links }}    
{% endautoescape %} {% if 'read' in resource.allowed_operations %}
- Read + Read
{% endif %} {% if 'create' in resource.allowed_operations %}
-
+ {% csrf_token %} - {{ create_form.as_p }} + {{ resource.form_instance.as_p }}
@@ -34,10 +35,10 @@ {% if 'update' in resource.allowed_operations %}
-
+ {% csrf_token %} - {{ create_form.as_p }} + {{ resource.form_instance.as_p }}
@@ -45,7 +46,7 @@ {% if 'delete' in resource.allowed_operations %}
-
+ {% csrf_token %} diff --git a/src/rest/templates/emitter.txt b/src/rest/templates/emitter.txt index 3bf094c65..78c619df4 100644 --- a/src/rest/templates/emitter.txt +++ b/src/rest/templates/emitter.txt @@ -1,4 +1,7 @@ -{% autoescape off %}HTTP Status {{ status }} -{% for key, val in headers.items %}{{ key }}: {{ val }} +{{ resource.name }} +{{ resource.description }} + +{% autoescape off %}HTTP/1.0 {{ resource.resp_status }} {{ resource.resp_status_text }} +{% for key, val in resource.resp_headers.items %}{{ key }}: {{ val }} {% endfor %} -{{ content }}{% endautoescape %} \ No newline at end of file +{{ content }}{% endautoescape %} diff --git a/src/testapp/views.py b/src/testapp/views.py index eca69cc33..dee0b19be 100644 --- a/src/testapp/views.py +++ b/src/testapp/views.py @@ -1,6 +1,8 @@ from rest.resource import Resource, ModelResource, QueryModelResource from testapp.models import BlogPost, Comment - + +##### Root Resource ##### + class RootResource(Resource): """This is the top level resource for the API. All the sub-resources are discoverable from here.""" @@ -11,49 +13,53 @@ class RootResource(Resource): 'blog-post': self.reverse(BlogPostCreator)}, {}) -# Blog Post Resources +##### Blog Post Resources ##### + +BLOG_POST_FIELDS = ('created', 'title', 'slug', 'content', 'absolute_url', 'comment_url', 'comments_url') class BlogPostList(QueryModelResource): """A resource which lists all existing blog posts.""" allowed_operations = ('read', ) model = BlogPost - + fields = BLOG_POST_FIELDS class BlogPostCreator(ModelResource): """A resource with which blog posts may be created.""" allowed_operations = ('create',) model = BlogPost - fields = ('created', 'title', 'slug', 'content', 'absolute_url', 'comment_url', 'comments_url') - + fields = BLOG_POST_FIELDS class BlogPostInstance(ModelResource): """A resource which represents a single blog post.""" allowed_operations = ('read', 'update', 'delete') model = BlogPost - fields = ('created', 'title', 'slug', 'content', 'absolute_url', 'comment_url', 'comments_url') + fields = BLOG_POST_FIELDS -# Comment Resources +##### Comment Resources ##### + +COMMENT_FIELDS = ('username', 'comment', 'created', 'rating', 'absolute_url', 'blogpost_url') class CommentList(QueryModelResource): """A resource which lists all existing comments for a given blog post.""" allowed_operations = ('read', ) model = Comment - + fields = COMMENT_FIELDS class CommentCreator(ModelResource): """A resource with which blog comments may be created for a given blog post.""" allowed_operations = ('create',) model = Comment - fields = ('username', 'comment', 'created', 'rating', 'absolute_url', 'blogpost_url') - + fields = COMMENT_FIELDS class CommentInstance(ModelResource): """A resource which represents a single comment.""" allowed_operations = ('read', 'update', 'delete') model = Comment - fields = ('username', 'comment', 'created', 'rating', 'absolute_url', 'blogpost_url') - + fields = COMMENT_FIELDS + + + # #'read-only-api': self.reverse(ReadOnlyResource), # 'write-only-api': self.reverse(WriteOnlyResource),