More tests, getting new serialization into resource

This commit is contained in:
Tom Christie 2011-05-10 16:01:58 +01:00
parent a2575c1191
commit 4d12679675
9 changed files with 173 additions and 36 deletions

View File

@ -3,7 +3,7 @@ The ``authentication`` module provides a set of pluggable authentication classes
Authentication behavior is provided by adding the ``AuthMixin`` class to a ``View`` . Authentication behavior is provided by adding the ``AuthMixin`` class to a ``View`` .
The set of authentication methods which are used is then specified by setting The set of authentication methods which are used is then specified by setting the
``authentication`` attribute on the ``View`` class, and listing a set of authentication classes. ``authentication`` attribute on the ``View`` class, and listing a set of authentication classes.
""" """
@ -81,7 +81,7 @@ class UserLoggedInAuthenticaton(BaseAuthenticaton):
""" """
def authenticate(self, request): def authenticate(self, request):
# TODO: Switch this back to request.POST, and let MultiPartParser deal with the consequences. # TODO: Switch this back to request.POST, and let FormParser/MultiPartParser deal with the consequences.
if getattr(request, 'user', None) and request.user.is_active: if getattr(request, 'user', None) and request.user.is_active:
# If this is a POST request we enforce CSRF validation. # If this is a POST request we enforce CSRF validation.
if request.method.upper() == 'POST': if request.method.upper() == 'POST':

View File

@ -33,7 +33,7 @@ __all__ = (
class RequestMixin(object): class RequestMixin(object):
""" """
Mixin class to provide request parsing behaviour. Mixin class to provide request parsing behavior.
""" """
USE_FORM_OVERLOADING = True USE_FORM_OVERLOADING = True
@ -93,6 +93,13 @@ class RequestMixin(object):
if content_length == 0: if content_length == 0:
return None return None
elif hasattr(request, 'read'): elif hasattr(request, 'read'):
# UPDATE BASED ON COMMENT BELOW:
#
# Yup, this was a bug in Django - fixed and waiting check in - see ticket 15785.
# http://code.djangoproject.com/ticket/15785
#
# COMMENT:
#
# It's not at all clear if this needs to be byte limited or not. # It's not at all clear if this needs to be byte limited or not.
# Maybe I'm just being dumb but it looks to me like there's some issues # Maybe I'm just being dumb but it looks to me like there's some issues
# with that in Django. # with that in Django.
@ -117,8 +124,6 @@ class RequestMixin(object):
#except (ValueError, TypeError): #except (ValueError, TypeError):
# content_length = 0 # content_length = 0
# self._stream = LimitedStream(request, content_length) # self._stream = LimitedStream(request, content_length)
#
# UPDATE: http://code.djangoproject.com/ticket/15785
self._stream = request self._stream = request
else: else:
self._stream = StringIO(request.raw_post_data) self._stream = StringIO(request.raw_post_data)
@ -290,11 +295,15 @@ class ResponseMixin(object):
return resp return resp
# TODO: This should be simpler now.
# Add a handles_response() to the renderer, then iterate through the
# acceptable media types, ordered by how specific they are,
# calling handles_response on each renderer.
def _determine_renderer(self, request): def _determine_renderer(self, request):
""" """
Return the appropriate renderer for the output, given the client's 'Accept' header, Return the appropriate renderer for the output, given the client's 'Accept' header,
and the content types that this mixin knows how to serve. and the content types that this mixin 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
""" """
@ -321,7 +330,7 @@ class ResponseMixin(object):
qvalue = Decimal('1.0') qvalue = Decimal('1.0')
if len(components) > 1: if len(components) > 1:
# Parse items that have a qvalue eg text/html;q=0.9 # Parse items that have a qvalue eg 'text/html; q=0.9'
try: try:
(q, num) = components[-1].split('=') (q, num) = components[-1].split('=')
if q == 'q': if q == 'q':
@ -356,10 +365,10 @@ class ResponseMixin(object):
raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE,
{'detail': 'Could not satisfy the client\'s Accept header', {'detail': 'Could not satisfy the client\'s Accept header',
'available_types': self.renderted_media_types}) 'available_types': self.rendered_media_types})
@property @property
def renderted_media_types(self): def rendered_media_types(self):
""" """
Return an list of all the media types that this resource can render. Return an list of all the media types that this resource can render.
""" """

View File

@ -24,6 +24,7 @@ from urllib import quote_plus
__all__ = ( __all__ = (
'BaseRenderer', 'BaseRenderer',
'TemplateRenderer',
'JSONRenderer', 'JSONRenderer',
'DocumentingHTMLRenderer', 'DocumentingHTMLRenderer',
'DocumentingXHTMLRenderer', 'DocumentingXHTMLRenderer',
@ -87,10 +88,12 @@ class DocumentingTemplateRenderer(BaseRenderer):
template = None template = None
def _get_content(self, view, request, obj, media_type): def _get_content(self, view, request, obj, media_type):
"""Get the content as if it had been rendered by a non-documenting renderer. """
Get the content as if it had been rendered by a non-documenting renderer.
(Typically this will be the content as it would have been if the Resource had been (Typically this will be the content as it would have been if the Resource had been
requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)""" requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)
"""
# Find the first valid renderer and render the content. (Don't use another documenting renderer.) # Find the first valid renderer and render the content. (Don't use another documenting renderer.)
renderers = [renderer for renderer in view.renderers if not isinstance(renderer, DocumentingTemplateRenderer)] renderers = [renderer for renderer in view.renderers if not isinstance(renderer, DocumentingTemplateRenderer)]
@ -103,12 +106,14 @@ class DocumentingTemplateRenderer(BaseRenderer):
return '[%d bytes of binary content]' return '[%d bytes of binary content]'
return content return content
def _get_form_instance(self, view): def _get_form_instance(self, view):
"""Get a form, possibly bound to either the input or output data. """
Get a form, possibly bound to either the input or output data.
In the absence on of the Resource having an associated form then In the absence on of the Resource having an associated form then
provide a form that can be used to submit arbitrary content.""" provide a form that can be used to submit arbitrary content.
"""
# Get the form instance if we have one bound to the input # Get the form instance if we have one bound to the input
form_instance = getattr(view, 'bound_form_instance', None) form_instance = getattr(view, 'bound_form_instance', None)
@ -138,8 +143,10 @@ class DocumentingTemplateRenderer(BaseRenderer):
def _get_generic_content_form(self, view): def _get_generic_content_form(self, view):
"""Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms """
(Which are typically application/x-www-form-urlencoded)""" Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms
(Which are typically application/x-www-form-urlencoded)
"""
# If we're not using content overloading there's no point in supplying a generic form, # If we're not using content overloading there's no point in supplying a generic form,
# as the view won't treat the form's value as the content of the request. # as the view won't treat the form's value as the content of the request.
@ -197,8 +204,8 @@ class DocumentingTemplateRenderer(BaseRenderer):
template = loader.get_template(self.template) template = loader.get_template(self.template)
context = RequestContext(self.view.request, { context = RequestContext(self.view.request, {
'content': content, 'content': content,
'resource': self.view, 'resource': self.view, # TODO: rename to view
'request': self.view.request, 'request': self.view.request, # TODO: remove
'response': self.view.response, 'response': self.view.response,
'description': description, 'description': description,
'name': name, 'name': name,

View File

@ -1,16 +1,89 @@
from django.db.models import Model from django.db import models
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.db.models.fields.related import RelatedField from django.db.models.fields.related import RelatedField
from django.utils.encoding import smart_unicode
import decimal import decimal
import inspect import inspect
import re import re
def _model_to_dict(instance, fields=None, exclude=None):
"""
This is a clone of Django's ``django.forms.model_to_dict`` except that it
doesn't coerce related objects into primary keys.
"""
opts = instance._meta
data = {}
for f in opts.fields + opts.many_to_many:
if not f.editable:
continue
if fields and not f.name in fields:
continue
if exclude and f.name in exclude:
continue
if isinstance(f, models.ForeignKey):
data[f.name] = getattr(instance, f.name)
else:
data[f.name] = f.value_from_object(instance)
return data
def _object_to_data(obj):
"""
Convert an object into a serializable representation.
"""
if isinstance(obj, dict):
# dictionaries
return dict([ (key, _object_to_data(val)) for key, val in obj.iteritems() ])
if isinstance(obj, (tuple, list, set, QuerySet)):
# basic iterables
return [_object_to_data(item) for item in obj]
if isinstance(obj, models.Manager):
# Manager objects
ret = [_object_to_data(item) for item in obj.all()]
if isinstance(obj, models.Model):
# Model instances
return _object_to_data(_model_to_dict(obj))
if isinstance(obj, decimal.Decimal):
# Decimals (force to string representation)
return str(obj)
if inspect.isfunction(obj) and not inspect.getargspec(obj)[0]:
# function with no args
return _object_to_data(obj())
if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 1:
# method with only a 'self' args
return _object_to_data(obj())
# fallback
return smart_unicode(obj, strings_only=True)
# TODO: Replace this with new Serializer code based on Forms API. # TODO: Replace this with new Serializer code based on Forms API.
#class Resource(object):
# def __init__(self, view):
# self.view = view
#
# def object_to_data(self, obj):
# pass
#
# def data_to_object(self, data, files):
# pass
#
#class FormResource(object):
# pass
#
#class ModelResource(object):
# pass
class Resource(object): class Resource(object):
"""A Resource determines how an object maps to a serializable entity. """
Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets.""" A Resource determines how a python object maps to some serializable data.
Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets.
"""
# The model attribute refers to the Django Model which this Resource maps to. # The model attribute refers to the Django Model which this Resource maps to.
# (The Model's class, rather than an instance of the Model) # (The Model's class, rather than an instance of the Model)
@ -50,7 +123,7 @@ class Resource(object):
ret = thing ret = thing
elif isinstance(thing, decimal.Decimal): elif isinstance(thing, decimal.Decimal):
ret = str(thing) ret = str(thing)
elif isinstance(thing, Model): elif isinstance(thing, models.Model):
ret = _model(thing, fields=fields) ret = _model(thing, fields=fields)
#elif isinstance(thing, HttpResponse): TRC #elif isinstance(thing, HttpResponse): TRC
# raise HttpStatusCode(thing) # raise HttpStatusCode(thing)

View File

@ -1,11 +1,14 @@
from django.core.handlers.wsgi import STATUS_CODE_TEXT from django.core.handlers.wsgi import STATUS_CODE_TEXT
__all__ =['Response', 'ErrorResponse'] __all__ = ('Response', 'ErrorResponse')
# TODO: remove raw_content/cleaned_content and just use content? # TODO: remove raw_content/cleaned_content and just use content?
class Response(object): class Response(object):
"""An HttpResponse that may include content that hasn't yet been serialized.""" """
An HttpResponse that may include content that hasn't yet been serialized.
"""
def __init__(self, status=200, content=None, headers={}): def __init__(self, status=200, content=None, headers={}):
self.status = status self.status = status
self.has_content_body = content is not None self.has_content_body = content is not None
@ -15,12 +18,18 @@ class Response(object):
@property @property
def status_text(self): def status_text(self):
"""Return reason text corresponding to our HTTP response status code. """
Provided for convenience.""" Return reason text corresponding to our HTTP response status code.
Provided for convenience.
"""
return STATUS_CODE_TEXT.get(self.status, '') return STATUS_CODE_TEXT.get(self.status, '')
class ErrorResponse(BaseException): class ErrorResponse(BaseException):
"""An exception representing an HttpResponse that should be returned immediately.""" """
An exception representing an Response that should be returned immediately.
Any content should be serialized as-is, without being filtered.
"""
def __init__(self, status, content=None, headers={}): def __init__(self, status, content=None, headers={}):
self.response = Response(status, content=content, headers=headers) self.response = Response(status, content=content, headers=headers)

View File

@ -1,7 +1,9 @@
"""Descriptive HTTP status codes, for code readability. """
Descriptive HTTP status codes, for code readability.
See RFC 2616 - Sec 10: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html See RFC 2616 - Sec 10: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
Also, django.core.handlers.wsgi.STATUS_CODE_TEXT""" Also see django.core.handlers.wsgi.STATUS_CODE_TEXT
"""
# Verbose format # Verbose format
HTTP_100_CONTINUE = 100 HTTP_100_CONTINUE = 100

View File

@ -48,7 +48,7 @@
<h2>GET {{ name }}</h2> <h2>GET {{ name }}</h2>
<div class='submit-row' style='margin: 0; border: 0'> <div class='submit-row' style='margin: 0; border: 0'>
<a href='{{ request.path }}' rel="nofollow" style='float: left'>GET</a> <a href='{{ request.path }}' rel="nofollow" style='float: left'>GET</a>
{% for media_type in resource.renderted_media_types %} {% for media_type in resource.rendered_media_types %}
{% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %}
[<a href='{{ request.path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>] [<a href='{{ request.path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>]
{% endwith %} {% endwith %}

View File

@ -0,0 +1,31 @@
"""Tests for the resource module"""
from django.test import TestCase
from djangorestframework.resource import _object_to_data
import datetime
import decimal
class TestObjectToData(TestCase):
"""Tests for the _object_to_data function"""
def test_decimal(self):
"""Decimals need to be converted to a string representation."""
self.assertEquals(_object_to_data(decimal.Decimal('1.5')), '1.5')
def test_function(self):
"""Functions with no arguments should be called."""
def foo():
return 1
self.assertEquals(_object_to_data(foo), 1)
def test_method(self):
"""Methods with only a ``self`` argument should be called."""
class Foo(object):
def foo(self):
return 1
self.assertEquals(_object_to_data(Foo().foo), 1)
def test_datetime(self):
"""datetime objects are left as-is."""
now = datetime.datetime.now()
self.assertEquals(_object_to_data(now), now)

View File

@ -31,20 +31,24 @@ class FormValidator(BaseValidator):
def validate(self, content): def validate(self, content):
"""Given some content as input return some cleaned, validated content. """
Given some content as input return some cleaned, validated content.
Raises a ErrorResponse with status code 400 (Bad Request) on failure. Raises a ErrorResponse with status code 400 (Bad Request) on failure.
Validation is standard form validation, with an additional constraint that no extra unknown fields may be supplied. Validation is standard form validation, with an additional constraint that no extra unknown fields may be supplied.
On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys. On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys.
If the 'errors' key exists it is a list of strings of non-field errors. If the 'errors' key exists it is a list of strings of non-field errors.
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.""" If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.
"""
return self._validate(content) return self._validate(content)
def _validate(self, content, allowed_extra_fields=()): def _validate(self, content, allowed_extra_fields=()):
"""Wrapped by validate to hide the extra_fields option that the ModelValidatorMixin uses. """
Wrapped by validate to hide the extra_fields option that the ModelValidatorMixin uses.
extra_fields is a list of fields which are not defined by the form, but which we still extra_fields is a list of fields which are not defined by the form, but which we still
expect to see on the input.""" expect to see on the input.
"""
bound_form = self.get_bound_form(content) bound_form = self.get_bound_form(content)
if bound_form is None: if bound_form is None:
@ -138,7 +142,8 @@ class ModelFormValidator(FormValidator):
# TODO: test the different validation here to allow for get get_absolute_url to be supplied on input and not bork out # TODO: test the different validation here to allow for get get_absolute_url to be supplied on input and not bork out
# TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.) # TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.)
def validate(self, content): def validate(self, content):
"""Given some content as input return some cleaned, validated content. """
Given some content as input return some cleaned, validated content.
Raises a ErrorResponse with status code 400 (Bad Request) on failure. Raises a ErrorResponse with status code 400 (Bad Request) on failure.
Validation is standard form or model form validation, Validation is standard form or model form validation,
@ -148,7 +153,8 @@ class ModelFormValidator(FormValidator):
On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys. On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys.
If the 'errors' key exists it is a list of strings of non-field errors. If the 'errors' key exists it is a list of strings of non-field errors.
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.""" If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.
"""
return self._validate(content, allowed_extra_fields=self._property_fields_set) return self._validate(content, allowed_extra_fields=self._property_fields_set)