diff options
| -rw-r--r-- | docs/topics/browser-enhancements.md | 49 | ||||
| -rw-r--r-- | rest_framework/decorators.py | 14 | ||||
| -rw-r--r-- | rest_framework/exceptions.py | 8 | ||||
| -rw-r--r-- | rest_framework/fields.py | 37 | ||||
| -rw-r--r-- | rest_framework/generics.py | 2 | ||||
| -rw-r--r-- | rest_framework/negotiation.py | 3 | ||||
| -rw-r--r-- | rest_framework/reverse.py | 8 | ||||
| -rw-r--r-- | rest_framework/serializers.py | 5 | ||||
| -rw-r--r-- | rest_framework/static/rest_framework/css/default.css | 14 | ||||
| -rw-r--r-- | rest_framework/templates/rest_framework/base.html | 2 | ||||
| -rw-r--r-- | rest_framework/templates/rest_framework/login.html | 56 | ||||
| -rw-r--r-- | rest_framework/tests/generics.py | 49 | ||||
| -rw-r--r-- | rest_framework/tests/models.py | 10 | ||||
| -rw-r--r-- | rest_framework/tests/serializer.py | 13 | ||||
| -rw-r--r-- | rest_framework/throttling.py | 2 | ||||
| -rw-r--r-- | rest_framework/views.py | 2 |
16 files changed, 198 insertions, 76 deletions
diff --git a/docs/topics/browser-enhancements.md b/docs/topics/browser-enhancements.md index d4e128ae..6a11f0fa 100644 --- a/docs/topics/browser-enhancements.md +++ b/docs/topics/browser-enhancements.md @@ -2,42 +2,63 @@ > "There are two noncontroversial uses for overloaded POST. The first is to *simulate* HTTP's uniform interface for clients like web browsers that don't support PUT or DELETE" > -> — [RESTful Web Services](1), Leonard Richardson & Sam Ruby. +> — [RESTful Web Services][cite], Leonard Richardson & Sam Ruby. ## Browser based PUT, DELETE, etc... -**TODO: Preamble.** Note that this is the same strategy as is used in [Ruby on Rails](2). +REST framework supports browser-based `PUT`, `DELETE` and other methods, by +overloading `POST` requests using a hidden form field. + +Note that this is the same strategy as is used in [Ruby on Rails][rails]. For example, given the following form: <form action="/news-items/5" method="POST"> - <input type="hidden" name="_method" value="DELETE"> - </form> + <input type="hidden" name="_method" value="DELETE"> + </form> `request.method` would return `"DELETE"`. ## Browser based submission of non-form content -Browser-based submission of content types other than form are supported by using form fields named `_content` and `_content_type`: +Browser-based submission of content types other than form are supported by +using form fields named `_content` and `_content_type`: For example, given the following form: <form action="/news-items/5" method="PUT"> - <input type="hidden" name="_content_type" value="application/json"> - <input name="_content" value="{'count': 1}"> - </form> + <input type="hidden" name="_content_type" value="application/json"> + <input name="_content" value="{'count': 1}"> + </form> -`request.content_type` would return `"application/json"`, and `request.stream` would return `"{'count': 1}"` +`request.content_type` would return `"application/json"`, and +`request.stream` would return `"{'count': 1}"` ## URL based accept headers +REST framework can take `?accept=application/json` style URL parameters, +which allow the `Accept` header to be overridden. + +This can be useful for testing the API from a web browser, where you don't +have any control over what is sent in the `Accept` header. + ## URL based format suffixes +REST framework can take `?format=json` style URL parameters, which can be a +useful shortcut for determing which content type should be returned from +the view. + +This is a more concise than using the `accept` override, but it also gives +you less control. (For example you can't specify any media type parameters) + ## Doesn't HTML5 support PUT and DELETE forms? -Nope. It was at one point intended to support `PUT` and `DELETE` forms, but was later [dropped from the spec](3). There remains [ongoing discussion](4) about adding support for `PUT` and `DELETE`, as well as how to support content types other than form-encoded data. +Nope. It was at one point intended to support `PUT` and `DELETE` forms, but +was later [dropped from the spec][html5]. There remains +[ongoing discussion][put_delete] about adding support for `PUT` and `DELETE`, +as well as how to support content types other than form-encoded data. -[1]: http://www.amazon.com/Restful-Web-Services-Leonard-Richardson/dp/0596529260 -[2]: http://guides.rubyonrails.org/form_helpers.html#how-do-forms-with-put-or-delete-methods-work -[3]: http://www.w3.org/TR/html5-diff/#changes-2010-06-24 -[4]: http://amundsen.com/examples/put-delete-forms/ +[cite]: http://www.amazon.com/Restful-Web-Services-Leonard-Richardson/dp/0596529260 +[rails]: http://guides.rubyonrails.org/form_helpers.html#how-do-forms-with-put-or-delete-methods-work +[html5]: http://www.w3.org/TR/html5-diff/#changes-2010-06-24 +[put_delete]: http://amundsen.com/examples/put-delete-forms/ diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 948973ae..a231f191 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -10,8 +10,18 @@ def api_view(http_method_names): def decorator(func): - class WrappedAPIView(APIView): - pass + WrappedAPIView = type( + 'WrappedAPIView', + (APIView,), + {'__doc__': func.__doc__} + ) + + # Note, the above allows us to set the docstring. + # It is the equivelent of: + # + # class WrappedAPIView(APIView): + # pass + # WrappedAPIView.__doc__ = func.doc <--- Not possible to do this allowed_methods = set(http_method_names) | set(('options',)) WrappedAPIView.http_method_names = [method.lower() for method in allowed_methods] diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py index 572425b9..89479deb 100644 --- a/rest_framework/exceptions.py +++ b/rest_framework/exceptions.py @@ -31,14 +31,6 @@ class PermissionDenied(APIException): self.detail = detail or self.default_detail -class InvalidFormat(APIException): - status_code = status.HTTP_404_NOT_FOUND - default_detail = "Format suffix '.%s' not found." - - def __init__(self, format, detail=None): - self.detail = (detail or self.default_detail) % format - - class MethodNotAllowed(APIException): status_code = status.HTTP_405_METHOD_NOT_ALLOWED default_detail = "Method '%s' not allowed." diff --git a/rest_framework/fields.py b/rest_framework/fields.py index ffc0c9d4..bb7d0918 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -43,7 +43,7 @@ class Field(object): Called to set up a field prior to field_to_native or field_from_native. parent - The parent serializer. - model_field - The model field this field corrosponds to, if one exists. + model_field - The model field this field corresponds to, if one exists. """ self.parent = parent self.root = parent.root or parent @@ -197,7 +197,7 @@ class WritableField(Field): class ModelField(WritableField): """ - A generic field that can be used against an arbirtrary model field. + A generic field that can be used against an arbitrary model field. """ def __init__(self, *args, **kwargs): try: @@ -337,14 +337,16 @@ class HyperlinkedRelatedField(RelatedField): self.view_name = kwargs.pop('view_name') except: raise ValueError("Hyperlinked field requires 'view_name' kwarg") + self.format = kwargs.pop('format', None) super(HyperlinkedRelatedField, self).__init__(*args, **kwargs) def to_native(self, obj): view_name = self.view_name request = self.context.get('request', None) + format = self.format or self.context.get('format', None) kwargs = {self.pk_url_kwarg: obj.pk} try: - return reverse(view_name, kwargs=kwargs, request=request) + return reverse(view_name, kwargs=kwargs, request=request, format=format) except: pass @@ -355,13 +357,13 @@ class HyperlinkedRelatedField(RelatedField): kwargs = {self.slug_url_kwarg: slug} try: - return reverse(self.view_name, kwargs=kwargs, request=request) + return reverse(self.view_name, kwargs=kwargs, request=request, format=format) except: pass kwargs = {self.pk_url_kwarg: obj.pk, self.slug_url_kwarg: slug} try: - return reverse(self.view_name, kwargs=kwargs, request=request) + return reverse(self.view_name, kwargs=kwargs, request=request, format=format) except: pass @@ -411,13 +413,15 @@ class HyperlinkedIdentityField(Field): # TODO: Make this mandatory, and have the HyperlinkedModelSerializer # set it on-the-fly self.view_name = kwargs.pop('view_name', None) + self.format = kwargs.pop('format', None) super(HyperlinkedIdentityField, self).__init__(*args, **kwargs) def field_to_native(self, obj, field_name): request = self.context.get('request', None) + format = self.format or self.context.get('format', None) view_name = self.view_name or self.parent.opts.view_name view_kwargs = {'pk': obj.pk} - return reverse(view_name, kwargs=view_kwargs, request=request) + return reverse(view_name, kwargs=view_kwargs, request=request, format=format) ##### Typed Fields ##### @@ -515,7 +519,10 @@ class EmailField(CharField): default_validators = [validators.validate_email] def from_native(self, value): - return super(EmailField, self).from_native(value).strip() + ret = super(EmailField, self).from_native(value) + if ret is None: + return None + return ret.strip() def __deepcopy__(self, memo): result = copy.copy(self) @@ -537,8 +544,9 @@ class DateField(WritableField): empty = None def from_native(self, value): - if value is None: - return value + if value in validators.EMPTY_VALUES: + return None + if isinstance(value, datetime.datetime): if timezone and settings.USE_TZ and timezone.is_aware(value): # Convert aware datetimes to the default time zone @@ -576,8 +584,9 @@ class DateTimeField(WritableField): empty = None def from_native(self, value): - if value is None: - return value + if value in validators.EMPTY_VALUES: + return None + if isinstance(value, datetime.datetime): return value if isinstance(value, datetime.date): @@ -635,6 +644,7 @@ class IntegerField(WritableField): def from_native(self, value): if value in validators.EMPTY_VALUES: return None + try: value = int(str(value)) except (ValueError, TypeError): @@ -650,8 +660,9 @@ class FloatField(WritableField): } def from_native(self, value): - if value is None: - return value + if value in validators.EMPTY_VALUES: + return None + try: return float(value) except (TypeError, ValueError): diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 190a5f79..27540a57 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -1,5 +1,5 @@ """ -Generic views that provide commmonly needed behaviour. +Generic views that provide commonly needed behaviour. """ from rest_framework import views, mixins diff --git a/rest_framework/negotiation.py b/rest_framework/negotiation.py index 444f8056..dae38477 100644 --- a/rest_framework/negotiation.py +++ b/rest_framework/negotiation.py @@ -1,3 +1,4 @@ +from django.http import Http404 from rest_framework import exceptions from rest_framework.settings import api_settings from rest_framework.utils.mediatypes import order_by_precedence, media_type_matches @@ -66,7 +67,7 @@ class DefaultContentNegotiation(BaseContentNegotiation): renderers = [renderer for renderer in renderers if renderer.format == format] if not renderers: - raise exceptions.InvalidFormat(format) + raise Http404 return renderers def get_accept_list(self, request): diff --git a/rest_framework/reverse.py b/rest_framework/reverse.py index ba663f98..c9db02f0 100644 --- a/rest_framework/reverse.py +++ b/rest_framework/reverse.py @@ -5,13 +5,15 @@ from django.core.urlresolvers import reverse as django_reverse from django.utils.functional import lazy -def reverse(viewname, *args, **kwargs): +def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra): """ Same as `django.core.urlresolvers.reverse`, but optionally takes a request and returns a fully qualified URL, using the request to get the base URL. """ - request = kwargs.pop('request', None) - url = django_reverse(viewname, *args, **kwargs) + if format is not None: + kwargs = kwargs or {} + kwargs['format'] = format + url = django_reverse(viewname, args=args, kwargs=kwargs, **extra) if request: return request.build_absolute_uri(url) return url diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ce04b3e2..16e2c835 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -279,7 +279,7 @@ class BaseSerializer(Field): def errors(self): """ Run deserialization and return error data, - setting self.object if no errors occured. + setting self.object if no errors occurred. """ if self._errors is None: obj = self.from_native(self.init_data) @@ -393,6 +393,9 @@ class ModelSerializer(Serializer): Creates a default instance of a basic non-relational field. """ kwargs = {} + if model_field.null: + kwargs['required'] = False + if model_field.has_default(): kwargs['required'] = False kwargs['default'] = model_field.get_default() diff --git a/rest_framework/static/rest_framework/css/default.css b/rest_framework/static/rest_framework/css/default.css index 739b9300..e29da395 100644 --- a/rest_framework/static/rest_framework/css/default.css +++ b/rest_framework/static/rest_framework/css/default.css @@ -32,6 +32,10 @@ h2, h3 { margin-right: 1em; } +ul.breadcrumb { + margin: 58px 0 0 0; +} + /* To allow tooltips to work on disabled elements */ .disabled-tooltip-shield { position: absolute; @@ -55,6 +59,7 @@ pre { .page-header { border-bottom: none; padding-bottom: 0px; + margin-bottom: 20px; } @@ -65,7 +70,7 @@ html{ background: none; } -body, .navbar .navbar-inner .container-fluid{ +body, .navbar .navbar-inner .container-fluid { max-width: 1150px; margin: 0 auto; } @@ -76,13 +81,14 @@ body{ } #content{ - margin: 40px 0 0 0; + margin: 0; } /* custom navigation styles */ .wrapper .navbar{ - width:100%; + width: 100%; position: absolute; - left:0; + left: 0; + top: 0; } .navbar .navbar-inner{ diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index 5ac6ef67..e0f79481 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -109,7 +109,7 @@ <div class="content-main"> <div class="page-header"><h1>{{ name }}</h1></div> - <p class="resource-description">{{ description }}</p> + {{ description }} <div class="request-info"> <pre class="prettyprint"><b>{{ request.method }}</b> {{ request.get_full_path }}</pre> diff --git a/rest_framework/templates/rest_framework/login.html b/rest_framework/templates/rest_framework/login.html index 65af512e..c1271399 100644 --- a/rest_framework/templates/rest_framework/login.html +++ b/rest_framework/templates/rest_framework/login.html @@ -3,42 +3,50 @@ <html> <head> - <link rel="stylesheet" type="text/css" href='{% get_static_prefix %}rest_framework/css/style.css'/> + <link rel="stylesheet" type="text/css" href="{% get_static_prefix %}rest_framework/css/bootstrap.min.css"/> + <link rel="stylesheet" type="text/css" href="{% get_static_prefix %}rest_framework/css/bootstrap-tweaks.css"/> + <link rel="stylesheet" type="text/css" href='{% get_static_prefix %}rest_framework/css/default.css'/> </head> - <body class="login"> + <body class="container"> - <div id="container"> - - <div id="header"> - <div id="branding"> - <h1 id="site-name">Django REST framework</h1> +<div class="container-fluid" style="margin-top: 30px"> + <div class="row-fluid"> + + <div class="well" style="width: 320px; margin-left: auto; margin-right: auto"> + <div class="row-fluid"> + <div> + <h3 style="margin: 0 0 20px;">Django REST framework</h3> </div> - </div> + </div><!-- /row fluid --> - <div id="content" class="colM"> - <div id="content-main"> - <form method="post" action="{% url 'rest_framework:login' %}" id="login-form"> + <div class="row-fluid"> + <div> + <form action="{% url 'rest_framework:login' %}" class=" form-inline" method="post"> {% csrf_token %} - <div class="form-row"> - <label for="id_username">Username:</label> {{ form.username }} + <div id="div_id_username" class="clearfix control-group"> + <div class="controls" style="height: 30px"> + <Label class="span4" style="margin-top: 3px">Username:</label> + <input style="height: 25px" type="text" name="username" maxlength="100" autocapitalize="off" autocorrect="off" class="textinput textInput" id="id_username"> + </div> </div> - <div class="form-row"> - <label for="id_password">Password:</label> {{ form.password }} - <input type="hidden" name="next" value="{{ next }}" /> + <div id="div_id_password" class="clearfix control-group"> + <div class="controls" style="height: 30px"> + <Label class="span4" style="margin-top: 3px">Password:</label> + <input style="height: 25px" type="password" name="password" maxlength="100" autocapitalize="off" autocorrect="off" class="textinput textInput" id="id_password"> + </div> </div> - <div class="form-row"> - <label> </label><input type="submit" value="Log in"> + <input type="hidden" name="next" value="{{ next }}" /> + <div class="form-actions-no-box"> + <input type="submit" name="submit" value="Log in" class="btn btn-primary" id="submit-id-submit"> </div> </form> - <script type="text/javascript"> - document.getElementById('id_username').focus() - </script> </div> - <br class="clear"> - </div> + </div><!-- /row fluid --> + </div><!--/span--> - <div id="footer"></div> + </div><!-- /.row-fluid --> + </div> </div> </body> diff --git a/rest_framework/tests/generics.py b/rest_framework/tests/generics.py index f4263478..d45ea976 100644 --- a/rest_framework/tests/generics.py +++ b/rest_framework/tests/generics.py @@ -2,7 +2,7 @@ from django.test import TestCase from django.test.client import RequestFactory from django.utils import simplejson as json from rest_framework import generics, serializers, status -from rest_framework.tests.models import BasicModel, Comment +from rest_framework.tests.models import BasicModel, Comment, SlugBasedModel factory = RequestFactory() @@ -22,6 +22,22 @@ class InstanceView(generics.RetrieveUpdateDestroyAPIView): model = BasicModel +class SlugSerializer(serializers.ModelSerializer): + slug = serializers.Field() # read only + + class Meta: + model = SlugBasedModel + exclude = ('id',) + + +class SlugBasedInstanceView(InstanceView): + """ + A model with a slug-field. + """ + model = SlugBasedModel + serializer_class = SlugSerializer + + class TestRootView(TestCase): def setUp(self): """ @@ -129,6 +145,7 @@ class TestInstanceView(TestCase): for obj in self.objects.all() ] self.view = InstanceView.as_view() + self.slug_based_view = SlugBasedInstanceView.as_view() def test_get_instance_view(self): """ @@ -198,7 +215,7 @@ class TestInstanceView(TestCase): def test_put_cannot_set_id(self): """ - POST requests to create a new object should not be able to set the id. + PUT requests to create a new object should not be able to set the id. """ content = {'id': 999, 'text': 'foobar'} request = factory.put('/1', json.dumps(content), @@ -224,6 +241,34 @@ class TestInstanceView(TestCase): updated = self.objects.get(id=1) self.assertEquals(updated.text, 'foobar') + def test_put_as_create_on_id_based_url(self): + """ + PUT requests to RetrieveUpdateDestroyAPIView should create an object + at the requested url if it doesn't exist. + """ + content = {'text': 'foobar'} + # pk fields can not be created on demand, only the database can set th pk for a new object + request = factory.put('/5', json.dumps(content), + content_type='application/json') + response = self.view(request, pk=5).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + new_obj = self.objects.get(pk=5) + self.assertEquals(new_obj.text, 'foobar') + + def test_put_as_create_on_slug_based_url(self): + """ + PUT requests to RetrieveUpdateDestroyAPIView should create an object + at the requested url if possible, else return HTTP_403_FORBIDDEN error-response. + """ + content = {'text': 'foobar'} + request = factory.put('/test_slug', json.dumps(content), + content_type='application/json') + response = self.slug_based_view(request, slug='test_slug').render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data, {'slug': 'test_slug', 'text': 'foobar'}) + new_obj = SlugBasedModel.objects.get(slug='test_slug') + self.assertEquals(new_obj.text, 'foobar') + # Regression test for #285 diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index d4ea729b..fb23e359 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -52,6 +52,11 @@ class BasicModel(RESTFrameworkModel): text = models.CharField(max_length=100) +class SlugBasedModel(RESTFrameworkModel): + text = models.CharField(max_length=100) + slug = models.SlugField(max_length=32) + + class DefaultValueModel(RESTFrameworkModel): text = models.CharField(default='foobar', max_length=100) @@ -111,3 +116,8 @@ class BlogPost(RESTFrameworkModel): class BlogPostComment(RESTFrameworkModel): text = models.TextField() blog_post = models.ForeignKey(BlogPost) + + +class Person(RESTFrameworkModel): + name = models.CharField(max_length=10) + age = models.IntegerField(null=True, blank=True) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 5df3bd7e..eb21dc46 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -43,6 +43,11 @@ class ActionItemSerializer(serializers.ModelSerializer): model = ActionItem +class PersonSerializer(serializers.ModelSerializer): + class Meta: + model = Person + + class BasicTests(TestCase): def setUp(self): self.comment = Comment( @@ -188,6 +193,14 @@ class ValidationTests(TestCase): self.assertFalse(serializer.is_valid()) self.assertEquals(serializer.errors, {'non_field_errors': [u'Email address not in content']}) + def test_null_is_true_fields(self): + """ + Omitting a value for null-field should validate. + """ + serializer = PersonSerializer({'name': 'marko'}) + self.assertEquals(serializer.is_valid(), True) + self.assertEquals(serializer.errors, {}) + class MetadataTests(TestCase): def test_empty(self): diff --git a/rest_framework/throttling.py b/rest_framework/throttling.py index 6860e6b9..8fe64248 100644 --- a/rest_framework/throttling.py +++ b/rest_framework/throttling.py @@ -16,7 +16,7 @@ class BaseThrottle(object): def wait(self): """ - Optionally, return a recommeded number of seconds to wait before + Optionally, return a recommended number of seconds to wait before the next request. """ return None diff --git a/rest_framework/views.py b/rest_framework/views.py index c721be3c..71e1fe6c 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -218,7 +218,7 @@ class APIView(View): def get_throttles(self): """ - Instantiates and returns the list of thottles that this view uses. + Instantiates and returns the list of throttles that this view uses. """ return [throttle() for throttle in self.throttle_classes] |
