Lots of good form validation and default actions

This commit is contained in:
Tom Christie 2011-01-04 17:42:23 +00:00
parent 48c7171aa0
commit f144b769fe
10 changed files with 615 additions and 123 deletions

View File

@ -4,11 +4,12 @@ import json
from utils import dict2xml from utils import dict2xml
class BaseEmitter(object): class BaseEmitter(object):
def __init__(self, resource, request, status, headers): def __init__(self, resource, request, status, headers, form):
self.request = request self.request = request
self.resource = resource self.resource = resource
self.status = status self.status = status
self.headers = headers self.headers = headers
self.form = form
def emit(self, output): def emit(self, output):
return output return output
@ -26,9 +27,8 @@ class TemplatedEmitter(BaseEmitter):
'headers': self.headers, 'headers': self.headers,
'resource_name': self.resource.__class__.__name__, 'resource_name': self.resource.__class__.__name__,
'resource_doc': self.resource.__doc__, 'resource_doc': self.resource.__doc__,
'create_form': self.resource.create_form and self.resource.create_form() or None, 'create_form': self.form,
'update_form': self.resource.update_form and self.resource.update_form() or None, 'update_form': self.form,
'allowed_methods': self.resource.allowed_methods,
'request': self.request, 'request': self.request,
'resource': self.resource, 'resource': self.resource,
}) })

View File

@ -17,6 +17,44 @@ class XMLParser(BaseParser):
pass pass
class FormParser(BaseParser): class FormParser(BaseParser):
def parse(self, input): """The default parser for form data.
return self.request.POST 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.data

View File

@ -1,6 +1,6 @@
from django.http import HttpResponse from django.http import HttpResponse
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from rest import emitters, parsers, utils from rest import emitters, parsers
from decimal import Decimal from decimal import Decimal
# #
@ -20,44 +20,103 @@ class ResourceException(Exception):
class Resource(object): class Resource(object):
# List of RESTful operations which may be performed on this resource.
allowed_operations = ('read',)
allowed_methods = ('GET',) # List of content-types the resource can respond with, ordered by preference
emitters = ( ('application/json', emitters.JSONEmitter),
callmap = { 'GET': 'read', 'POST': 'create',
'PUT': 'update', 'DELETE': 'delete' }
emitters = [ ('application/json', emitters.JSONEmitter),
('text/html', emitters.HTMLEmitter), ('text/html', emitters.HTMLEmitter),
('application/xhtml+xml', emitters.HTMLEmitter), ('application/xhtml+xml', emitters.HTMLEmitter),
('text/plain', emitters.TextEmitter), ('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, parsers = { 'application/json': parsers.JSONParser,
'application/xml': parsers.XMLParser, 'application/xml': parsers.XMLParser,
'application/x-www-form-urlencoded': parsers.FormParser, 'application/x-www-form-urlencoded': parsers.FormParser,
'multipart/form-data': parsers.FormParser } 'multipart/form-data': parsers.FormParser }
create_form = None # Optional form for input validation and presentation of HTML formatted responses.
update_form = None 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' METHOD_PARAM = '_method'
ACCEPT_PARAM = '_accept' 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): def __new__(cls, request, *args, **kwargs):
"""Make the class callable so it can be used as a Django view."""
self = object.__new__(cls) self = object.__new__(cls)
self.__init__() self.__init__()
self._request = request 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): def __init__(self):
pass pass
def _determine_method(self, request): def reverse(self, view, *args, **kwargs):
"""Determine the HTTP method that this request should be treated as, """Return a fully qualified URI for a given view or resource, using the current request as the base URI.
allowing for PUT and DELETE tunneling via the _method parameter.""" 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 method = request.method
if method == 'POST' and request.POST.has_key(self.METHOD_PARAM): if method == 'POST' and request.POST.has_key(self.METHOD_PARAM):
@ -66,17 +125,47 @@ class Resource(object):
return method return method
def _check_method_allowed(self, method): def check_method_allowed(self, method):
if not method in self.allowed_methods: """Ensure the request method is acceptable fot this resource."""
raise ResourceException(STATUS_405_METHOD_NOT_ALLOWED, if not method in self.CALLMAP.keys():
{'detail': 'Method \'%s\' not allowed on this resource.' % method})
if not method in self.callmap.keys():
raise ResourceException(STATUS_501_NOT_IMPLEMENTED, raise ResourceException(STATUS_501_NOT_IMPLEMENTED,
{'detail': 'Unknown or unsupported method \'%s\'' % method}) {'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_parser(self, request):
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.
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):
"""Return the appropriate parser for the input, given the client's 'Content-Type' header, """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.""" and the content types that this Resource knows how to parse."""
content_type = request.META.get('CONTENT_TYPE', 'application/x-www-form-urlencoded') content_type = request.META.get('CONTENT_TYPE', 'application/x-www-form-urlencoded')
@ -91,13 +180,12 @@ class Resource(object):
raise ResourceException(STATUS_415_UNSUPPORTED_MEDIA_TYPE, raise ResourceException(STATUS_415_UNSUPPORTED_MEDIA_TYPE,
{'detail': 'Unsupported content type \'%s\'' % content_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, """Return the appropriate emitter for the output, given the client's 'Accept' header,
and the content types that this Resource knows how to serve. 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] default = self.emitters[0]
if not request.META.has_key('HTTP_ACCEPT'): if not request.META.has_key('HTTP_ACCEPT'):
@ -142,60 +230,60 @@ class Resource(object):
'accepted_types': [item[0] for item in self.emitters]}) '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): def _handle_request(self, request, *args, **kwargs):
"""
# Hack to ensure PUT requests get the same form treatment as POST requests Broadly this consists of the following procedure:
utils.coerce_put_post(request)
# Get the request method, allowing for PUT and DELETE tunneling
method = self._determine_method(request)
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: try:
self._check_method_allowed(method) # Before we attempt anything else determine what format to emit our response data with.
mimetype, emitter = self.determine_emitter(request)
# Parse the HTTP Request content # Ensure the requested operation is permitted on this resource
func = getattr(self, self.callmap.get(method, '')) self.check_method_allowed(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'): 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 = 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) (status, ret, headers) = func(data, request.META, *args, **kwargs)
else: else:
(status, ret, headers) = func(request.META, *args, **kwargs) (status, ret, headers) = func(request.META, *args, **kwargs)
except ResourceException, exc: except ResourceException, exc:
(status, ret, headers) = (exc.status, exc.content, exc.headers) (status, ret, headers) = (exc.status, exc.content, exc.headers)
headers['Allow'] = ', '.join(self.allowed_methods) # Use a default emitter if request failed without being able to determine an acceptable emitter
if emitter is None:
# Serialize the HTTP Response content
try:
mimetype, emitter = self._determine_emitter(request)
except ResourceException, exc:
(status, ret, headers) = (exc.status, exc.content, exc.headers)
mimetype, emitter = self.emitters[0] mimetype, emitter = self.emitters[0]
content = emitter(self, request, status, headers).emit(ret) # 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])
# Serialize the response content
ret = self.cleanup_response(ret)
content = emitter(self, request, status, headers, form).emit(ret)
# Build the HTTP Response # Build the HTTP Response
resp = HttpResponse(content, mimetype=mimetype, status=status) resp = HttpResponse(content, mimetype=mimetype, status=status)
@ -204,24 +292,293 @@ class Resource(object):
return resp 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, )}) 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 cleanup_request(self, data, form=None):
"""Filter data into form-cleaned data, performing validation and type coercion."""
if form is None:
return data
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})
return form.cleaned_data
def cleanup_response(self, data):
"""
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): def read(self, headers={}, *args, **kwargs):
self._not_implemented('read') instance = self.model.objects.get(**kwargs)
return (200, instance, {})
def create(self, data=None, headers={}, *args, **kwargs): def update(self, data, headers={}, *args, **kwargs):
self._not_implemented('create') instance = self.model.objects.get(**kwargs)
for (key, val) in data.items():
def update(self, data=None, headers={}, *args, **kwargs): setattr(instance, key, val)
self._not_implemented('update') instance.save()
return (200, instance, {})
def delete(self, headers={}, *args, **kwargs): def delete(self, headers={}, *args, **kwargs):
self._not_implemented('delete') instance = self.model.objects.get(**kwargs)
instance.delete()
def reverse(self, view, *args, **kwargs): return (204, '', {})
"""Return a fully qualified URI for a view, using the current request as the base URI.
"""
return self._request.build_absolute_uri(reverse(view, *args, **kwargs))

View File

@ -12,17 +12,17 @@
<h1>{{ resource_name }}</h1> <h1>{{ resource_name }}</h1>
<p>{{ resource_doc }}</p> <p>{{ resource_doc }}</p>
<pre>{% autoescape off %}<b>{{ status }} {{ reason }}</b> <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 %} {% endfor %}
{{ content|urlize_quoted_links }}{% endautoescape %} </pre> {{ content|urlize_quoted_links }}{% endautoescape %} </pre>
{% if 'GET' in allowed_methods %} {% if 'read' in resource.allowed_operations %}
<div class='action'> <div class='action'>
<a href='{{ request.path }}'>Read</a> <a href='{{ request.path }}'>Read</a>
</div> </div>
{% endif %} {% endif %}
{% if 'POST' in resource.allowed_methods %} {% if 'create' in resource.allowed_operations %}
<div class='action'> <div class='action'>
<form action="{{ request.path }}" method="POST"> <form action="{{ request.path }}" method="POST">
{% csrf_token %} {% csrf_token %}
@ -32,7 +32,7 @@
</div> </div>
{% endif %} {% endif %}
{% if 'PUT' in resource.allowed_methods %} {% if 'update' in resource.allowed_operations %}
<div class='action'> <div class='action'>
<form action="{{ request.path }}" method="POST"> <form action="{{ request.path }}" method="POST">
<input type="hidden" name="{{ resource.METHOD_PARAM}}" value="PUT" /> <input type="hidden" name="{{ resource.METHOD_PARAM}}" value="PUT" />
@ -43,7 +43,7 @@
</div> </div>
{% endif %} {% endif %}
{% if 'DELETE' in resource.allowed_methods %} {% if 'delete' in resource.allowed_operations %}
<div class='action'> <div class='action'>
<form action="{{ request.path }}" method="POST"> <form action="{{ request.path }}" method="POST">
{% csrf_token %} {% csrf_token %}

Binary file not shown.

View File

@ -1,3 +1,31 @@
from django.db import models 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)

View File

@ -9,7 +9,8 @@ from django.test import TestCase
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from testapp import views from testapp import views
import json import json
from rest.utils import xml2dict, dict2xml #from rest.utils import xml2dict, dict2xml
class AcceptHeaderTests(TestCase): class AcceptHeaderTests(TestCase):
def assert_accept_mimetype(self, mimetype, expect=None, expect_match=True): def assert_accept_mimetype(self, mimetype, expect=None, expect_match=True):
@ -46,6 +47,10 @@ class AcceptHeaderTests(TestCase):
resp = self.client.get(reverse(views.ReadOnlyResource), HTTP_ACCEPT='invalid/invalid') resp = self.client.get(reverse(views.ReadOnlyResource), HTTP_ACCEPT='invalid/invalid')
self.assertEquals(resp.status_code, 406) self.assertEquals(resp.status_code, 406)
def test_prefer_specific(self):
self.fail("Test not implemented")
class AllowedMethodsTests(TestCase): class AllowedMethodsTests(TestCase):
def test_reading_read_only_allowed(self): def test_reading_read_only_allowed(self):
resp = self.client.get(reverse(views.ReadOnlyResource)) resp = self.client.get(reverse(views.ReadOnlyResource))
@ -63,6 +68,7 @@ class AllowedMethodsTests(TestCase):
resp = self.client.put(reverse(views.WriteOnlyResource), {}) resp = self.client.put(reverse(views.WriteOnlyResource), {})
self.assertEquals(resp.status_code, 200) self.assertEquals(resp.status_code, 200)
class EncodeDecodeTests(TestCase): class EncodeDecodeTests(TestCase):
def setUp(self): def setUp(self):
super(self.__class__, self).setUp() super(self.__class__, self).setUp()
@ -70,36 +76,71 @@ class EncodeDecodeTests(TestCase):
def test_encode_form_decode_json(self): def test_encode_form_decode_json(self):
content = self.input 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) output = json.loads(resp.content)
self.assertEquals(self.input, output) self.assertEquals(self.input, output)
def test_encode_json_decode_json(self): def test_encode_json_decode_json(self):
content = json.dumps(self.input) 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) output = json.loads(resp.content)
self.assertEquals(self.input, output) self.assertEquals(self.input, output)
def test_encode_xml_decode_json(self): #def test_encode_xml_decode_json(self):
content = dict2xml(self.input) # content = dict2xml(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', 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) output = json.loads(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_form_decode_xml(self): class CreatedModelTests(TestCase):
content = self.input def setUp(self):
resp = self.client.put(reverse(views.WriteOnlyResource), content, HTTP_ACCEPT='application/xml') content = json.dumps({'name': 'example'})
output = xml2dict(resp.content) resp = self.client.post(reverse(views.ContainerFactory), content, 'application/json', HTTP_ACCEPT='application/json')
self.assertEquals(self.input, output) self.container = json.loads(resp.content)
def test_encode_json_decode_xml(self): def test_read_container(self):
content = json.dumps(self.input) resp = self.client.get(self.container["absolute_uri"])
resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml') self.assertEquals(resp.status_code, 200)
output = xml2dict(resp.content) container = json.loads(resp.content)
self.assertEquals(self.input, output) 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)
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)

View File

@ -5,4 +5,7 @@ urlpatterns = patterns('testapp.views',
(r'^read-only$', 'ReadOnlyResource'), (r'^read-only$', 'ReadOnlyResource'),
(r'^write-only$', 'WriteOnlyResource'), (r'^write-only$', 'WriteOnlyResource'),
(r'^read-write$', 'ReadWriteResource'), (r'^read-write$', 'ReadWriteResource'),
(r'^model$', 'ModelFormResource'),
(r'^container$', 'ContainerFactory'),
(r'^container/((?P<key>[^/]+))$', 'ContainerInstance'),
) )

View File

@ -1,21 +1,24 @@
from rest.resource import Resource from rest.resource import Resource, ModelResource
from testapp.forms import ExampleForm from testapp.forms import ExampleForm
from testapp.models import ExampleModel, ExampleContainer
class RootResource(Resource): class RootResource(Resource):
"""This is my docstring """This is my docstring
""" """
allowed_methods = ('GET',) allowed_operations = ('read',)
def read(self, headers={}, *args, **kwargs): def read(self, headers={}, *args, **kwargs):
return (200, {'read-only-api': self.reverse(ReadOnlyResource), return (200, {'read-only-api': self.reverse(ReadOnlyResource),
'write-only-api': self.reverse(WriteOnlyResource), '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): class ReadOnlyResource(Resource):
"""This is my docstring """This is my docstring
""" """
allowed_methods = ('GET',) allowed_operations = ('read',)
def read(self, headers={}, *args, **kwargs): def read(self, headers={}, *args, **kwargs):
return (200, {'ExampleString': 'Example', return (200, {'ExampleString': 'Example',
@ -26,13 +29,35 @@ class ReadOnlyResource(Resource):
class WriteOnlyResource(Resource): class WriteOnlyResource(Resource):
"""This is my docstring """This is my docstring
""" """
allowed_methods = ('PUT',) allowed_operations = ('update',)
def update(self, data, headers={}, *args, **kwargs): def update(self, data, headers={}, *args, **kwargs):
return (200, data, {}) return (200, data, {})
class ReadWriteResource(Resource): class ReadWriteResource(Resource):
allowed_methods = ('GET', 'PUT', 'DELETE') allowed_operations = ('read', 'update', 'delete')
create_form = ExampleForm create_form = ExampleForm
update_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',)