aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorTom Christie2011-01-04 17:42:23 +0000
committerTom Christie2011-01-04 17:42:23 +0000
commitf144b769fedd421f3ec24dfd3a4f10c681192337 (patch)
treea3ebc0fe0cdd38aad6e3a40f04d62b49fbee51d5 /src
parent48c7171aa05cd69ab8d9cd6f3a8eed52f18792a4 (diff)
downloaddjango-rest-framework-f144b769fedd421f3ec24dfd3a4f10c681192337.tar.bz2
Lots of good form validation and default actions
Diffstat (limited to 'src')
-rw-r--r--src/rest/emitters.py12
-rw-r--r--src/rest/parsers.py40
-rw-r--r--src/rest/resource.py515
-rw-r--r--src/rest/templates/emitter.html10
-rw-r--r--src/rest/templatetags/__init__.pycbin171 -> 163 bytes
-rw-r--r--src/rest/templatetags/urlize_quoted_links.pycbin4539 -> 4515 bytes
-rw-r--r--src/testapp/models.py30
-rw-r--r--src/testapp/tests.py87
-rw-r--r--src/testapp/urls.py3
-rw-r--r--src/testapp/views.py37
10 files changed, 613 insertions, 121 deletions
diff --git a/src/rest/emitters.py b/src/rest/emitters.py
index 5dad624c..bafbf372 100644
--- a/src/rest/emitters.py
+++ b/src/rest/emitters.py
@@ -4,12 +4,13 @@ import json
from utils import dict2xml
class BaseEmitter(object):
- def __init__(self, resource, request, status, headers):
+ def __init__(self, resource, request, status, headers, form):
self.request = request
self.resource = resource
self.status = status
self.headers = headers
-
+ self.form = form
+
def emit(self, output):
return output
@@ -26,14 +27,13 @@ class TemplatedEmitter(BaseEmitter):
'headers': self.headers,
'resource_name': self.resource.__class__.__name__,
'resource_doc': self.resource.__doc__,
- 'create_form': self.resource.create_form and self.resource.create_form() or None,
- 'update_form': self.resource.update_form and self.resource.update_form() or None,
- 'allowed_methods': self.resource.allowed_methods,
+ 'create_form': self.form,
+ 'update_form': self.form,
'request': self.request,
'resource': self.resource,
})
return template.render(context)
-
+
class JSONEmitter(BaseEmitter):
def emit(self, output):
return json.dumps(output)
diff --git a/src/rest/parsers.py b/src/rest/parsers.py
index 0f914471..85b0e51f 100644
--- a/src/rest/parsers.py
+++ b/src/rest/parsers.py
@@ -17,6 +17,44 @@ class XMLParser(BaseParser):
pass
class FormParser(BaseParser):
+ """The default parser for form data.
+ Return a dict containing a single value for each non-reserved parameter
+ """
+ def __init__(self, resource, request):
+
+ if request.method == 'PUT':
+ # Fix from piston to force Django to give PUT requests the same
+ # form processing that POST requests get...
+ #
+ # Bug fix: if _load_post_and_files has already been called, for
+ # example by middleware accessing request.POST, the below code to
+ # pretend the request is a POST instead of a PUT will be too late
+ # to make a difference. Also calling _load_post_and_files will result
+ # in the following exception:
+ # AttributeError: You cannot set the upload handlers after the upload has been processed.
+ # The fix is to check for the presence of the _post field which is set
+ # the first time _load_post_and_files is called (both by wsgi.py and
+ # modpython.py). If it's set, the request has to be 'reset' to redo
+ # the query value parsing in POST mode.
+ if hasattr(request, '_post'):
+ del request._post
+ del request._files
+
+ try:
+ request.method = "POST"
+ request._load_post_and_files()
+ request.method = "PUT"
+ except AttributeError:
+ request.META['REQUEST_METHOD'] = 'POST'
+ request._load_post_and_files()
+ request.META['REQUEST_METHOD'] = 'PUT'
+
+ #
+ self.data = {}
+ for (key, val) in request.POST.items():
+ if key not in resource.RESERVED_PARAMS:
+ self.data[key] = val
+
def parse(self, input):
- return self.request.POST
+ return self.data
diff --git a/src/rest/resource.py b/src/rest/resource.py
index 4d8dd542..85aea8cf 100644
--- a/src/rest/resource.py
+++ b/src/rest/resource.py
@@ -1,6 +1,6 @@
from django.http import HttpResponse
from django.core.urlresolvers import reverse
-from rest import emitters, parsers, utils
+from rest import emitters, parsers
from decimal import Decimal
#
@@ -20,44 +20,103 @@ class ResourceException(Exception):
class Resource(object):
+ # List of RESTful operations which may be performed on this resource.
+ allowed_operations = ('read',)
- allowed_methods = ('GET',)
-
- callmap = { 'GET': 'read', 'POST': 'create',
- 'PUT': 'update', 'DELETE': 'delete' }
-
- emitters = [ ('application/json', emitters.JSONEmitter),
+ # List of content-types the resource can respond with, ordered by preference
+ emitters = ( ('application/json', emitters.JSONEmitter),
('text/html', emitters.HTMLEmitter),
('application/xhtml+xml', emitters.HTMLEmitter),
('text/plain', emitters.TextEmitter),
- ('application/xml', emitters.XMLEmitter), ]
+ ('application/xml', emitters.XMLEmitter), )
+ # List of content-types the resource can read from
parsers = { 'application/json': parsers.JSONParser,
'application/xml': parsers.XMLParser,
'application/x-www-form-urlencoded': parsers.FormParser,
'multipart/form-data': parsers.FormParser }
- create_form = None
- update_form = None
+ # 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' }
+ REVERSE_CALLMAP = dict([(val, key) for (key, val) in CALLMAP.items()])
+ # Some reserved parameters to allow us to use standard HTML forms with our resource.
METHOD_PARAM = '_method'
ACCEPT_PARAM = '_accept'
+ CSRF_PARAM = 'csrfmiddlewaretoken'
+ RESERVED_PARAMS = set((METHOD_PARAM, ACCEPT_PARAM, CSRF_PARAM))
+
+ USE_SITEMAP_FOR_ABSOLUTE_URLS = False
def __new__(cls, request, *args, **kwargs):
+ """Make the class callable so it can be used as a Django view."""
self = object.__new__(cls)
self.__init__()
self._request = request
- return self._handle_request(request, *args, **kwargs)
+ try:
+ return self._handle_request(request, *args, **kwargs)
+ except:
+ import traceback
+ traceback.print_exc()
+ raise
+
def __init__(self):
pass
- def _determine_method(self, request):
- """Determine the HTTP method that this request should be treated as,
- allowing for PUT and DELETE tunneling via the _method parameter."""
+ 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.
+
+ Provided for convienience."""
+ return self._request.build_absolute_uri(reverse(view, *args, **kwargs))
+
+
+ def make_absolute(self, uri):
+ """Given a relative URI, return an absolute URI using the current request as the base URI.
+ TODO: Add SITEMAP option.
+
+ Provided for convienience."""
+ return self._request.build_absolute_uri(uri)
+
+
+ def read(self, headers={}, *args, **kwargs):
+ """RESTful read on the resource, which must be subclassed to be implemented. Should be a safe operation."""
+ self.not_implemented('read')
+
+
+ def create(self, data=None, headers={}, *args, **kwargs):
+ """RESTful create on the resource, which must be subclassed to be implemented."""
+ self.not_implemented('create')
+
+
+ def update(self, data=None, headers={}, *args, **kwargs):
+ """RESTful update on the resource, which must be subclassed to be implemented. Should be an idempotent operation."""
+ self.not_implemented('update')
+
+
+ def delete(self, headers={}, *args, **kwargs):
+ """RESTful delete on the resource, which must be subclassed to be implemented. Should be an idempotent operation."""
+ self.not_implemented('delete')
+
+
+ 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,
+ {'detail': '%s operation on this resource has not been implemented' % (operation, )})
+
+
+ def determine_method(self, request):
+ """Determine the HTTP method that this request should be treated as.
+ Allow for PUT and DELETE tunneling via the _method parameter."""
method = request.method
if method == 'POST' and request.POST.has_key(self.METHOD_PARAM):
@@ -66,17 +125,47 @@ class Resource(object):
return method
- def _check_method_allowed(self, method):
- if not method in self.allowed_methods:
+ def check_method_allowed(self, method):
+ """Ensure the request method is acceptable fot this resource."""
+ if not method in self.CALLMAP.keys():
+ raise ResourceException(STATUS_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,
{'detail': 'Method \'%s\' not allowed on this resource.' % method})
+
+
+
+ def determine_form(self, data=None):
+ """Optionally return a Django Form instance, which may be used for validation
+ and/or rendered by an HTML/XHTML emitter.
- if not method in self.callmap.keys():
- raise ResourceException(STATUS_501_NOT_IMPLEMENTED,
- {'detail': 'Unknown or unsupported method \'%s\'' % method})
+ The data argument will be non Null if the form is required to be bound to some deserialized
+ input data, or Null if the form is required to be unbound.
+ """
+ if self.form:
+ return self.form(data)
+ return None
+
+
+ def cleanup_request(self, data, form=None):
+ """Perform any resource-specific data deserialization and/or validation
+ after the initial HTTP content-type deserialization has taken place.
+
+ Optionally this may use a Django Form which will have been bound to the data,
+ rather than using the data directly.
+ """
+ return data
+
+
+ def cleanup_response(self, data):
+ """Perform any resource-specific data filtering prior to the standard HTTP
+ content-type serialization."""
+ return data
- def _determine_parser(self, request):
+ def determine_parser(self, request):
"""Return the appropriate parser for the input, given the client's 'Content-Type' header,
and the content types that this Resource knows how to parse."""
content_type = request.META.get('CONTENT_TYPE', 'application/x-www-form-urlencoded')
@@ -90,14 +179,13 @@ class Resource(object):
except KeyError:
raise ResourceException(STATUS_415_UNSUPPORTED_MEDIA_TYPE,
{'detail': 'Unsupported content type \'%s\'' % content_type})
-
- def _determine_emitter(self, request):
+
+
+ def determine_emitter(self, request):
"""Return the appropriate emitter for the output, given the client's 'Accept' header,
and the content types that this Resource knows how to serve.
- See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
- """
-
+ See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html"""
default = self.emitters[0]
if not request.META.has_key('HTTP_ACCEPT'):
@@ -141,61 +229,61 @@ class Resource(object):
{'detail': 'Could not statisfy the client\'s accepted content type',
'accepted_types': [item[0] for item in self.emitters]})
-
- def _validate_data(self, method, data):
- """If there is an appropriate form to deal with this operation,
- then validate the data and return the resulting dictionary.
- """
- if method == 'PUT' and self.update_form:
- form = self.update_form(data)
- elif method == 'POST' and self.create_form:
- form = self.create_form(data)
- else:
- return data
-
- if not form.is_valid():
- raise ResourceException(STATUS_400_BAD_REQUEST,
- {'detail': dict((k, map(unicode, v))
- for (k,v) in form.errors.iteritems())})
-
- return form.cleaned_data
-
def _handle_request(self, request, *args, **kwargs):
+ """
+
+ Broadly this consists of the following procedure:
+
+ 0. ensure the operation is permitted
+ 1. deserialize request content into request data, using standard HTTP content types (PUT/POST only)
+ 2. cleanup and validate request data (PUT/POST only)
+ 3. call the core method to get the response data
+ 4. cleanup the response data
+ 5. serialize response data into response content, using standard HTTP content negotiation
+ """
+ method = self.determine_method(request)
+ emitter = None
+ form = None
+ try:
+ # Before we attempt anything else determine what format to emit our response data with.
+ mimetype, emitter = self.determine_emitter(request)
- # Hack to ensure PUT requests get the same form treatment as POST requests
- utils.coerce_put_post(request)
-
- # Get the request method, allowing for PUT and DELETE tunneling
- method = self._determine_method(request)
+ # Ensure the requested operation is permitted on this resource
+ self.check_method_allowed(method)
- try:
- self._check_method_allowed(method)
-
- # Parse the HTTP Request content
- func = getattr(self, self.callmap.get(method, ''))
+ # Get the appropriate create/read/update/delete function
+ func = getattr(self, self.CALLMAP.get(method, ''))
+ # Either generate the response data, deserializing and validating any request data
if method in ('PUT', 'POST'):
- parser = self._determine_parser(request)
+ parser = self.determine_parser(request)
data = parser(self, request).parse(request.raw_post_data)
- data = self._validate_data(method, data)
+ form = self.determine_form(data)
+ data = self.cleanup_request(data, form)
(status, ret, headers) = func(data, request.META, *args, **kwargs)
else:
(status, ret, headers) = func(request.META, *args, **kwargs)
- except ResourceException, exc:
- (status, ret, headers) = (exc.status, exc.content, exc.headers)
- headers['Allow'] = ', '.join(self.allowed_methods)
-
- # Serialize the HTTP Response content
- try:
- mimetype, emitter = self._determine_emitter(request)
+
except ResourceException, exc:
(status, ret, headers) = (exc.status, exc.content, exc.headers)
+
+ # Use a default emitter if request failed without being able to determine an acceptable emitter
+ if emitter is None:
mimetype, emitter = self.emitters[0]
+
+ # Use a form unbound to any data if one has not yet been created
+ if form is None:
+ form = self.determine_form()
+
+ # Always add the allow header
+ headers['Allow'] = ', '.join([self.REVERSE_CALLMAP[operation] for operation in self.allowed_operations])
- content = emitter(self, request, status, headers).emit(ret)
+ # Serialize the response content
+ ret = self.cleanup_response(ret)
+ content = emitter(self, request, status, headers, form).emit(ret)
# Build the HTTP Response
resp = HttpResponse(content, mimetype=mimetype, status=status)
@@ -204,24 +292,293 @@ class Resource(object):
return resp
- def _not_implemented(self, operation):
- resource_name = self.__class__.__name__
- raise ResourceException(STATUS_500_INTERNAL_SERVER_ERROR,
- {'detail': '%s operation on this resource has not been implemented' % (operation, )})
- def read(self, headers={}, *args, **kwargs):
- self._not_implemented('read')
- def create(self, data=None, headers={}, *args, **kwargs):
- self._not_implemented('create')
+
+from django.forms import ModelForm
+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):
+ """Return a form that may be used in validation and/or rendering an html emitter"""
+ if self.form:
+ return self.form
+
+ elif self.model:
+ class NewModelForm(ModelForm):
+ class Meta:
+ model = self.model
+ fields = self.form_fields if self.form_fields else self.fields
+
+ if data is None:
+ return NewModelForm()
+ else:
+ return NewModelForm(data)
+
+ else:
+ return None
- def update(self, data=None, headers={}, *args, **kwargs):
- self._not_implemented('update')
+ def cleanup_request(self, data, form=None):
+ """Filter data into form-cleaned data, performing validation and type coercion."""
+ if form is None:
+ return data
- def delete(self, headers={}, *args, **kwargs):
- self._not_implemented('delete')
+ if not form.is_valid():
+ details = dict((key, map(unicode, val)) for (key, val) in form.errors.iteritems())
+ raise ResourceException(STATUS_400_BAD_REQUEST, {'detail': details})
- def reverse(self, view, *args, **kwargs):
- """Return a fully qualified URI for a view, using the current request as the base URI.
+ return form.cleaned_data
+
+ def cleanup_response(self, data):
"""
- return self._request.build_absolute_uri(reverse(view, *args, **kwargs))
+ Recursively serialize a lot of types, and
+ in cases where it doesn't recognize the type,
+ it will fall back to Django's `smart_unicode`.
+
+ Returns `dict`.
+ """
+
+ 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, decimal.Decimal):
+ ret = str(thing)
+ elif isinstance(thing, 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, '__emittable__'):
+ f = thing.__emittable__
+ if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1:
+ ret = _any(f())
+ else:
+ ret = str(thing) # TRC TODO: Change this back!
+
+ 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_uri = False
+
+ if handler or fields:
+ v = lambda f: getattr(data, f.attname)
+
+ if not fields:
+ """
+ Fields was not specified, try to find teh correct
+ version in the typemapper we were sent.
+ """
+ mapped = self.in_typemapper(type(data), self.anonymous)
+ get_fields = set(mapped.fields)
+ exclude_fields = set(mapped.exclude).difference(get_fields)
+
+ if not get_fields:
+ get_fields = set([ f.attname.replace("_id", "", 1)
+ for f in data._meta.fields ])
+
+ # sets can be negated.
+ for exclude in exclude_fields:
+ if isinstance(exclude, basestring):
+ get_fields.discard(exclude)
+
+ elif isinstance(exclude, re._pattern_type):
+ for field in get_fields.copy():
+ if exclude.match(field):
+ get_fields.discard(field)
+
+ else:
+ get_fields = set(fields)
+
+ if 'absolute_uri' in get_fields: # MOVED (TRC)
+ get_absolute_uri = 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:
+ for f in data._meta.fields:
+ ret[f.attname] = _any(getattr(data, f.attname))
+
+ fields = dir(data.__class__) + ret.keys()
+ add_ons = [k for k in dir(data) if k not in 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_uri:
+ try: ret['absolute_uri'] = self.make_absolute(data.get_absolute_url())
+ except: pass
+
+ 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(data, self.fields)
+
+
+ def create(self, data, headers={}):
+ instance = self.model(**data)
+ instance.save()
+ headers = {}
+ if hasattr(instance, 'get_absolute_url'):
+ headers['Location'] = self.make_absolute(instance.get_absolute_url())
+ return (201, instance, headers)
+
+ def read(self, headers={}, *args, **kwargs):
+ instance = self.model.objects.get(**kwargs)
+ return (200, instance, {})
+
+ def update(self, data, headers={}, *args, **kwargs):
+ instance = self.model.objects.get(**kwargs)
+ for (key, val) in data.items():
+ setattr(instance, key, val)
+ instance.save()
+ return (200, instance, {})
+
+ def delete(self, headers={}, *args, **kwargs):
+ instance = self.model.objects.get(**kwargs)
+ instance.delete()
+ return (204, '', {}) \ No newline at end of file
diff --git a/src/rest/templates/emitter.html b/src/rest/templates/emitter.html
index f5d0df08..8be41b7c 100644
--- a/src/rest/templates/emitter.html
+++ b/src/rest/templates/emitter.html
@@ -12,17 +12,17 @@
<h1>{{ resource_name }}</h1>
<p>{{ resource_doc }}</p>
<pre>{% autoescape off %}<b>{{ status }} {{ reason }}</b>
-{% for key, val in headers.items %}<b>{{ key }}:</b> {{ val }}
+{% for key, val in headers.items %}<b>{{ key }}:</b> {{ val|urlize_quoted_links }}
{% endfor %}
{{ content|urlize_quoted_links }}{% endautoescape %} </pre>
-{% if 'GET' in allowed_methods %}
+{% if 'read' in resource.allowed_operations %}
<div class='action'>
<a href='{{ request.path }}'>Read</a>
</div>
{% endif %}
-{% if 'POST' in resource.allowed_methods %}
+{% if 'create' in resource.allowed_operations %}
<div class='action'>
<form action="{{ request.path }}" method="POST">
{% csrf_token %}
@@ -32,7 +32,7 @@
</div>
{% endif %}
-{% if 'PUT' in resource.allowed_methods %}
+{% if 'update' in resource.allowed_operations %}
<div class='action'>
<form action="{{ request.path }}" method="POST">
<input type="hidden" name="{{ resource.METHOD_PARAM}}" value="PUT" />
@@ -43,7 +43,7 @@
</div>
{% endif %}
-{% if 'DELETE' in resource.allowed_methods %}
+{% if 'delete' in resource.allowed_operations %}
<div class='action'>
<form action="{{ request.path }}" method="POST">
{% csrf_token %}
diff --git a/src/rest/templatetags/__init__.pyc b/src/rest/templatetags/__init__.pyc
index 9daf8783..69527f63 100644
--- a/src/rest/templatetags/__init__.pyc
+++ b/src/rest/templatetags/__init__.pyc
Binary files differ
diff --git a/src/rest/templatetags/urlize_quoted_links.pyc b/src/rest/templatetags/urlize_quoted_links.pyc
index 37e480ac..b49e16b6 100644
--- a/src/rest/templatetags/urlize_quoted_links.pyc
+++ b/src/rest/templatetags/urlize_quoted_links.pyc
Binary files differ
diff --git a/src/testapp/models.py b/src/testapp/models.py
index 71a83623..75304c9c 100644
--- a/src/testapp/models.py
+++ b/src/testapp/models.py
@@ -1,3 +1,31 @@
from django.db import models
+import uuid
-# Create your models here.
+def uuid_str():
+ return str(uuid.uuid1())
+
+class ExampleModel(models.Model):
+ num = models.IntegerField(default=2, choices=((1,'one'), (2, 'two')))
+ hidden_num = models.IntegerField(verbose_name='Something', help_text='HELP')
+ text = models.TextField(blank=False)
+ another = models.CharField(max_length=10)
+
+
+class ExampleContainer(models.Model):
+ """Container. Has a key, a name, and some internal data, and contains a set of items."""
+ key = models.CharField(primary_key=True, default=uuid_str, max_length=36, editable=False)
+ name = models.CharField(max_length=256)
+ internal = models.IntegerField(default=0)
+
+ @models.permalink
+ def get_absolute_url(self):
+ return ('testapp.views.ContainerInstance', [self.key])
+
+
+class ExampleItem(models.Model):
+ """Item. Belongs to a container and has an index number and a note.
+ Items are uniquely identified by their container and index number."""
+ container = models.ForeignKey(ExampleContainer, related_name='items')
+ index = models.IntegerField()
+ note = models.CharField(max_length=1024)
+ unique_together = (container, index) \ No newline at end of file
diff --git a/src/testapp/tests.py b/src/testapp/tests.py
index c4e7dee3..0e2cde63 100644
--- a/src/testapp/tests.py
+++ b/src/testapp/tests.py
@@ -9,7 +9,8 @@ from django.test import TestCase
from django.core.urlresolvers import reverse
from testapp import views
import json
-from rest.utils import xml2dict, dict2xml
+#from rest.utils import xml2dict, dict2xml
+
class AcceptHeaderTests(TestCase):
def assert_accept_mimetype(self, mimetype, expect=None, expect_match=True):
@@ -45,6 +46,10 @@ class AcceptHeaderTests(TestCase):
def test_invalid_accept_header_returns_406(self):
resp = self.client.get(reverse(views.ReadOnlyResource), HTTP_ACCEPT='invalid/invalid')
self.assertEquals(resp.status_code, 406)
+
+ def test_prefer_specific(self):
+ self.fail("Test not implemented")
+
class AllowedMethodsTests(TestCase):
def test_reading_read_only_allowed(self):
@@ -63,6 +68,7 @@ class AllowedMethodsTests(TestCase):
resp = self.client.put(reverse(views.WriteOnlyResource), {})
self.assertEquals(resp.status_code, 200)
+
class EncodeDecodeTests(TestCase):
def setUp(self):
super(self.__class__, self).setUp()
@@ -70,36 +76,71 @@ class EncodeDecodeTests(TestCase):
def test_encode_form_decode_json(self):
content = self.input
- resp = self.client.put(reverse(views.WriteOnlyResource), content, HTTP_ACCEPT='application/json')
+ resp = self.client.put(reverse(views.WriteOnlyResource), content)
output = json.loads(resp.content)
self.assertEquals(self.input, output)
def test_encode_json_decode_json(self):
content = json.dumps(self.input)
- resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/json')
+ resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json')
output = json.loads(resp.content)
self.assertEquals(self.input, output)
- def test_encode_xml_decode_json(self):
- content = dict2xml(self.input)
- resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/json')
+ #def test_encode_xml_decode_json(self):
+ # content = dict2xml(self.input)
+ # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/json')
+ # output = json.loads(resp.content)
+ # self.assertEquals(self.input, output)
+
+ #def test_encode_form_decode_xml(self):
+ # content = self.input
+ # resp = self.client.put(reverse(views.WriteOnlyResource), content, HTTP_ACCEPT='application/xml')
+ # output = xml2dict(resp.content)
+ # self.assertEquals(self.input, output)
+
+ #def test_encode_json_decode_xml(self):
+ # content = json.dumps(self.input)
+ # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
+ # output = xml2dict(resp.content)
+ # self.assertEquals(self.input, output)
+
+ #def test_encode_xml_decode_xml(self):
+ # content = dict2xml(self.input)
+ # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
+ # output = xml2dict(resp.content)
+ # self.assertEquals(self.input, output)
+
+class ModelTests(TestCase):
+ def test_create_container(self):
+ content = json.dumps({'name': 'example'})
+ resp = self.client.post(reverse(views.ContainerFactory), content, 'application/json')
output = json.loads(resp.content)
- self.assertEquals(self.input, output)
-
- def test_encode_form_decode_xml(self):
- content = self.input
- resp = self.client.put(reverse(views.WriteOnlyResource), content, HTTP_ACCEPT='application/xml')
- output = xml2dict(resp.content)
- self.assertEquals(self.input, output)
+ self.assertEquals(resp.status_code, 201)
+ self.assertEquals(output['name'], 'example')
+ self.assertEquals(set(output.keys()), set(('absolute_uri', 'name', 'key')))
- def test_encode_json_decode_xml(self):
- content = json.dumps(self.input)
- resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
- output = xml2dict(resp.content)
- self.assertEquals(self.input, output)
+class CreatedModelTests(TestCase):
+ def setUp(self):
+ content = json.dumps({'name': 'example'})
+ resp = self.client.post(reverse(views.ContainerFactory), content, 'application/json', HTTP_ACCEPT='application/json')
+ self.container = json.loads(resp.content)
- def test_encode_xml_decode_xml(self):
- content = dict2xml(self.input)
- resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
- output = xml2dict(resp.content)
- self.assertEquals(self.input, output) \ No newline at end of file
+ def test_read_container(self):
+ resp = self.client.get(self.container["absolute_uri"])
+ self.assertEquals(resp.status_code, 200)
+ container = json.loads(resp.content)
+ self.assertEquals(container, self.container)
+
+ def test_delete_container(self):
+ resp = self.client.delete(self.container["absolute_uri"])
+ self.assertEquals(resp.status_code, 204)
+ self.assertEquals(resp.content, '')
+
+ def test_update_container(self):
+ self.container['name'] = 'new'
+ content = json.dumps(self.container)
+ resp = self.client.put(self.container["absolute_uri"], content, 'application/json')
+ self.assertEquals(resp.status_code, 200)
+ container = json.loads(resp.content)
+ self.assertEquals(container, self.container)
+ \ No newline at end of file
diff --git a/src/testapp/urls.py b/src/testapp/urls.py
index bbdde8a3..b90590db 100644
--- a/src/testapp/urls.py
+++ b/src/testapp/urls.py
@@ -5,4 +5,7 @@ urlpatterns = patterns('testapp.views',
(r'^read-only$', 'ReadOnlyResource'),
(r'^write-only$', 'WriteOnlyResource'),
(r'^read-write$', 'ReadWriteResource'),
+ (r'^model$', 'ModelFormResource'),
+ (r'^container$', 'ContainerFactory'),
+ (r'^container/((?P<key>[^/]+))$', 'ContainerInstance'),
)
diff --git a/src/testapp/views.py b/src/testapp/views.py
index d9160af6..f121efa3 100644
--- a/src/testapp/views.py
+++ b/src/testapp/views.py
@@ -1,21 +1,24 @@
-from rest.resource import Resource
+from rest.resource import Resource, ModelResource
from testapp.forms import ExampleForm
+from testapp.models import ExampleModel, ExampleContainer
class RootResource(Resource):
"""This is my docstring
"""
- allowed_methods = ('GET',)
+ allowed_operations = ('read',)
def read(self, headers={}, *args, **kwargs):
return (200, {'read-only-api': self.reverse(ReadOnlyResource),
'write-only-api': self.reverse(WriteOnlyResource),
- 'read-write-api': self.reverse(ReadWriteResource)}, {})
+ 'read-write-api': self.reverse(ReadWriteResource),
+ 'model-api': self.reverse(ModelFormResource),
+ 'create-container': self.reverse(ContainerFactory)}, {})
class ReadOnlyResource(Resource):
"""This is my docstring
"""
- allowed_methods = ('GET',)
+ allowed_operations = ('read',)
def read(self, headers={}, *args, **kwargs):
return (200, {'ExampleString': 'Example',
@@ -26,13 +29,35 @@ class ReadOnlyResource(Resource):
class WriteOnlyResource(Resource):
"""This is my docstring
"""
- allowed_methods = ('PUT',)
+ allowed_operations = ('update',)
def update(self, data, headers={}, *args, **kwargs):
return (200, data, {})
class ReadWriteResource(Resource):
- allowed_methods = ('GET', 'PUT', 'DELETE')
+ allowed_operations = ('read', 'update', 'delete')
create_form = ExampleForm
update_form = ExampleForm
+
+
+class ModelFormResource(ModelResource):
+ allowed_operations = ('read', 'update', 'delete')
+ model = ExampleModel
+
+# Nice things: form validation is applied to any input type
+# html forms for output
+# output always serialized nicely
+class ContainerFactory(ModelResource):
+ allowed_operations = ('create',)
+ model = ExampleContainer
+ fields = ('absolute_uri', 'name', 'key')
+ form_fields = ('name',)
+
+
+class ContainerInstance(ModelResource):
+ allowed_operations = ('read', 'update', 'delete')
+ model = ExampleContainer
+ fields = ('absolute_uri', 'name', 'key')
+ form_fields = ('name',)
+