From f144b769fedd421f3ec24dfd3a4f10c681192337 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 4 Jan 2011 17:42:23 +0000 Subject: [PATCH] Lots of good form validation and default actions --- src/rest/emitters.py | 12 +- src/rest/parsers.py | 42 +- src/rest/resource.py | 519 +++++++++++++++--- src/rest/templates/emitter.html | 10 +- src/rest/templatetags/__init__.pyc | Bin 171 -> 163 bytes src/rest/templatetags/urlize_quoted_links.pyc | Bin 4539 -> 4515 bytes src/testapp/models.py | 30 +- src/testapp/tests.py | 85 ++- src/testapp/urls.py | 3 + src/testapp/views.py | 37 +- 10 files changed, 615 insertions(+), 123 deletions(-) diff --git a/src/rest/emitters.py b/src/rest/emitters.py index 5dad624c3..bafbf372e 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 0f9144713..85b0e51f7 100644 --- a/src/rest/parsers.py +++ b/src/rest/parsers.py @@ -17,6 +17,44 @@ class XMLParser(BaseParser): pass class FormParser(BaseParser): - def parse(self, input): - return self.request.POST + """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.data diff --git a/src/rest/resource.py b/src/rest/resource.py index 4d8dd542d..85aea8cfa 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: - raise ResourceException(STATUS_405_METHOD_NOT_ALLOWED, - {'detail': 'Method \'%s\' not allowed on this resource.' % method}) - - if not method in self.callmap.keys(): + 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_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, 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: - # 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) - + 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: - self._check_method_allowed(method) - - # Parse the HTTP Request content - func = getattr(self, self.callmap.get(method, '')) + # Before we attempt anything else determine what format to emit our response data with. + mimetype, emitter = self.determine_emitter(request) + + # Ensure the requested operation is permitted on this resource + 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'): - 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, )}) + + + +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): - self._not_implemented('read') + instance = self.model.objects.get(**kwargs) + return (200, instance, {}) - def create(self, data=None, headers={}, *args, **kwargs): - self._not_implemented('create') - - def update(self, data=None, headers={}, *args, **kwargs): - self._not_implemented('update') + 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): - self._not_implemented('delete') - - def reverse(self, view, *args, **kwargs): - """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)) + 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 f5d0df080..8be41b7cf 100644 --- a/src/rest/templates/emitter.html +++ b/src/rest/templates/emitter.html @@ -12,17 +12,17 @@

{{ resource_name }}

{{ resource_doc }}

{% autoescape off %}{{ status }} {{ reason }}
-{% for key, val in headers.items %}{{ key }}: {{ val }}
+{% for key, val in headers.items %}{{ key }}: {{ val|urlize_quoted_links }}
 {% endfor %}
 {{ content|urlize_quoted_links }}{% endautoescape %}    
-{% if 'GET' in allowed_methods %} +{% if 'read' in resource.allowed_operations %}
Read
{% endif %} -{% if 'POST' in resource.allowed_methods %} +{% if 'create' in resource.allowed_operations %}
{% csrf_token %} @@ -32,7 +32,7 @@
{% endif %} -{% if 'PUT' in resource.allowed_methods %} +{% if 'update' in resource.allowed_operations %}
@@ -43,7 +43,7 @@
{% endif %} -{% if 'DELETE' in resource.allowed_methods %} +{% if 'delete' in resource.allowed_operations %}
{% csrf_token %} diff --git a/src/rest/templatetags/__init__.pyc b/src/rest/templatetags/__init__.pyc index 9daf8783f59b5dd51da386a34478c0670ee581e9..69527f63db4bb8a1a773f526ee26c45266f761f4 100644 GIT binary patch delta 34 qcmZ3@xR{ac;wN6N%k>l4%msWH7#Q?Ji&Kk=^-J<|^}{EIh5-P@Ukfh) delta 42 ycmZ3?xSEme;wN4%$tx4t%q2n?7#Q?Ji&Kk=^-J<|lQW7ki%T+7^~)#vh5-O2V-8IK diff --git a/src/rest/templatetags/urlize_quoted_links.pyc b/src/rest/templatetags/urlize_quoted_links.pyc index 37e480ac4c2a4e24e2305c157146c473572a54d8..b49e16b67ab6eb018194b20a1a86719fba7b5a09 100644 GIT binary patch delta 88 zcmdn3yjYp-;wN6N$=w^-e7FU|85kJ!LyJ?3iuFtKbM?a~>j(yJ-p$?0giXe0^Af%S HtTO!o(iIyk delta 116 zcmZ3iyjz*=;wN4%$txS#e7Ggz85kJ!LyJ?3iuFtKbCWZQGK)(xQ}xRyd+_;8P7q|> VyqUX|38$*f9(;Rn%RHCZPZT diff --git a/src/testapp/models.py b/src/testapp/models.py index 71a836239..75304c9ce 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 c4e7dee32..0e2cde634 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) + 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): - content = self.input - resp = self.client.put(reverse(views.WriteOnlyResource), content, 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_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_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_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_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 bbdde8a37..b90590db7 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[^/]+))$', 'ContainerInstance'), ) diff --git a/src/testapp/views.py b/src/testapp/views.py index d9160af62..f121efa36 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',) +