diff options
43 files changed, 1215 insertions, 487 deletions
@@ -1,11 +1,13 @@ Tom Christie <tomchristie> - tom@tomchristie.com, @thisneonsoul Paul Bagwell <pbgwl> - Suggestions & bugfixes. -Marko Tibold <markotibold> - Contributions & Providing the Hudson CI Server. +Marko Tibold <markotibold> - Contributions & Providing the Jenkins CI Server. Sébastien Piquemal <sebpiq> - Contributions. Carmen Wick <cwick> - Bugfixes. Alex Ehlke <aehlke> - Design Contributions. +Alen Mujezinovic <flashingpumpkin> - Contributions. THANKS TO: + Jesper Noehr <jespern> & the django-piston contributors for providing the starting point for this project. -And of course, to the Django core team and the Django community at large. +And of course, to the Django core team and the Django community at large. You guys rock. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..fc9ce976 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +recursive-include djangorestframework/static *.ico *.txt +recursive-include djangorestframework/templates *.txt *.html +recursive-include examples .keep *.py *.txt +recursive-include docs *.py *.rst *.html *.txt +include AUTHORS LICENSE requirements.txt tox.ini @@ -1,36 +1,99 @@ -# To install django-rest-framework in a virtualenv environment... +General Notes +------------- -hg clone https://tomchristie@bitbucket.org/tomchristie/django-rest-framework -cd django-rest-framework/ -virtualenv --no-site-packages --distribute --python=python2.6 env -source ./env/bin/activate -pip install -r requirements.txt # django, coverage +To install django-rest-framework in a virtualenv environment -# To run the tests... + hg clone https://tomchristie@bitbucket.org/tomchristie/django-rest-framework + cd django-rest-framework/ + virtualenv --no-site-packages --distribute --python=python2.6 env + source ./env/bin/activate + pip install -r requirements.txt # django, coverage -export PYTHONPATH=. # Ensure djangorestframework is on the PYTHONPATH -python djangorestframework/runtests/runtests.py +To run the tests -# To run the test coverage report... + export PYTHONPATH=. # Ensure djangorestframework is on the PYTHONPATH + python djangorestframework/runtests/runtests.py -export PYTHONPATH=. # Ensure djangorestframework is on the PYTHONPATH -python djangorestframework/runtests/runcoverage.py -# To run the examples... +To run the test coverage report -pip install -r examples/requirements.txt # pygments, httplib2, markdown -cd examples -export PYTHONPATH=.. -python manage.py syncdb -python manage.py runserver + export PYTHONPATH=. # Ensure djangorestframework is on the PYTHONPATH + python djangorestframework/runtests/runcoverage.py -# To build the documentation... -pip install -r docs/requirements.txt # sphinx -sphinx-build -c docs -b html -d docs/build docs html +To run the examples -# To run the tests against the full set of supported configurations + pip install -r examples/requirements.txt # pygments, httplib2, markdown + cd examples + export PYTHONPATH=.. + python manage.py syncdb + python manage.py runserver -deactivate # Ensure we are not currently running in a virtualenv -tox + +To build the documentation + + pip install -r docs/requirements.txt # sphinx + sphinx-build -c docs -b html -d docs/build docs html + + +To run the tests against the full set of supported configurations + + deactivate # Ensure we are not currently running in a virtualenv + tox + + +To create the sdist packages + + python setup.py sdist --formats=gztar,zip + + + +Release Notes +============= + +0.2.3 + + * Fix some throttling bugs + * X-Throttle header on throttling + * Support for nesting resources on related models + +0.2.2 + + * Throttling support complete + +0.2.1 + + * Couple of simple bugfixes over 0.2.0 + +0.2.0 + + * Big refactoring changes since 0.1.0, ask on the discussion group if anything isn't clear. + The public API has been massively cleaned up. Expect it to be fairly stable from here on in. + + * `Resource` becomes decoupled into `View` and `Resource`, your views should now inherit from `View`, not `Resource`. + + * The handler functions on views .get() .put() .post() etc, no longer have the `content` and `auth` args. + Use `self.CONTENT` inside a view to access the deserialized, validated content. + Use `self.user` inside a view to access the authenticated user. + + * `allowed_methods` and `anon_allowed_methods` are now defunct. if a method is defined, it's available. + The `permissions` attribute on a `View` is now used to provide generic permissions checking. + Use permission classes such as `FullAnonAccess`, `IsAuthenticated` or `IsUserOrIsAnonReadOnly` to set the permissions. + + * The `authenticators` class becomes `authentication`. Class names change to Authentication. + + * The `emitters` class becomes `renderers`. Class names change to Renderers. + + * `ResponseException` becomes `ErrorResponse`. + + * The mixin classes have been nicely refactored, the basic mixins are now `RequestMixin`, `ResponseMixin`, `AuthMixin`, and `ResourceMixin` + You can reuse these mixin classes individually without using the `View` class. + +0.1.1 + + * Final build before pulling in all the refactoring changes for 0.2, in case anyone needs to hang on to 0.1. + +0.1.0 + + * Initial release.
\ No newline at end of file diff --git a/djangorestframework/__init__.py b/djangorestframework/__init__.py index 26482435..b1ef6dda 100644 --- a/djangorestframework/__init__.py +++ b/djangorestframework/__init__.py @@ -1 +1,3 @@ -VERSION="0.2.0" +__version__ = '0.2.3' + +VERSION = __version__ # synonym diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py index 7d6e2114..be22103e 100644 --- a/djangorestframework/authentication.py +++ b/djangorestframework/authentication.py @@ -13,13 +13,13 @@ from djangorestframework.utils import as_tuple import base64 __all__ = ( - 'BaseAuthenticaton', - 'BasicAuthenticaton', - 'UserLoggedInAuthenticaton' + 'BaseAuthentication', + 'BasicAuthentication', + 'UserLoggedInAuthentication' ) -class BaseAuthenticaton(object): +class BaseAuthentication(object): """ All authentication classes should extend BaseAuthentication. """ @@ -47,7 +47,7 @@ class BaseAuthenticaton(object): return None -class BasicAuthenticaton(BaseAuthenticaton): +class BasicAuthentication(BaseAuthentication): """ Use HTTP Basic authentication. """ @@ -78,7 +78,7 @@ class BasicAuthenticaton(BaseAuthenticaton): return None -class UserLoggedInAuthenticaton(BaseAuthenticaton): +class UserLoggedInAuthentication(BaseAuthentication): """ Use Django's session framework for authentication. """ diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 11e3bb38..910d06ae 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -466,7 +466,7 @@ class InstanceMixin(object): # We do a little dance when we store the view callable... # we need to store it wrapped in a 1-tuple, so that inspect will treat it # as a function when we later look it up (rather than turning it into a method). - # This makes sure our URL reversing works ok. + # This makes sure our URL reversing works ok. resource.view_callable = (view,) return view @@ -479,6 +479,7 @@ class ReadModelMixin(object): """ def get(self, request, *args, **kwargs): model = self.resource.model + try: if args: # If we have any none kwargs then assume the last represents the primrary key @@ -498,6 +499,7 @@ class CreateModelMixin(object): """ def post(self, request, *args, **kwargs): model = self.resource.model + # translated 'related_field' kwargs into 'related_field_id' for related_name in [field.name for field in model._meta.fields if isinstance(field, RelatedField)]: if kwargs.has_key(related_name): @@ -522,6 +524,7 @@ class UpdateModelMixin(object): """ def put(self, request, *args, **kwargs): model = self.resource.model + # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url try: if args: @@ -547,6 +550,7 @@ class DeleteModelMixin(object): """ def delete(self, request, *args, **kwargs): model = self.resource.model + try: if args: # If we have any none kwargs then assume the last represents the primrary key @@ -581,8 +585,15 @@ class ListModelMixin(object): queryset = None def get(self, request, *args, **kwargs): - queryset = self.queryset if self.queryset else self.resource.model.objects.all() - ordering = getattr(self.resource, 'ordering', None) + model = self.resource.model + + queryset = self.queryset if self.queryset else model.objects.all() + + if hasattr(self, 'resource'): + ordering = getattr(self.resource, 'ordering', None) + else: + ordering = None + if ordering: args = as_tuple(ordering) queryset = queryset.order_by(*args) diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 726e09e9..3346a26e 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -11,12 +11,11 @@ We need a method to be able to: and multipart/form-data. (eg also handle multipart/json) """ +from django.http import QueryDict from django.http.multipartparser import MultiPartParser as DjangoMultiPartParser from django.utils import simplejson as json from djangorestframework import status -from djangorestframework.compat import parse_qs from djangorestframework.response import ErrorResponse -from djangorestframework.utils import as_tuple from djangorestframework.utils.mediatypes import media_type_matches __all__ = ( @@ -117,7 +116,7 @@ class FormParser(BaseParser): `data` will be a :class:`QueryDict` containing all the form parameters. `files` will always be :const:`None`. """ - data = parse_qs(stream.read(), keep_blank_values=True) + data = QueryDict(stream.read()) return (data, None) diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py index 1f6151f8..7dcabcf0 100644 --- a/djangorestframework/permissions.py +++ b/djangorestframework/permissions.py @@ -1,6 +1,6 @@ """ The :mod:`permissions` module bundles a set of permission classes that are used -for checking if a request passes a certain set of constraints. You can assign a permision +for checking if a request passes a certain set of constraints. You can assign a permission class to your view by setting your View's :attr:`permissions` class attribute. """ @@ -15,7 +15,9 @@ __all__ = ( 'IsAuthenticated', 'IsAdminUser', 'IsUserOrIsAnonReadOnly', - 'PerUserThrottling' + 'PerUserThrottling', + 'PerViewThrottling', + 'PerResourceThrottling' ) @@ -24,12 +26,11 @@ _403_FORBIDDEN_RESPONSE = ErrorResponse( {'detail': 'You do not have permission to access this resource. ' + 'You may need to login or otherwise authenticate the request.'}) -_503_THROTTLED_RESPONSE = ErrorResponse( +_503_SERVICE_UNAVAILABLE = ErrorResponse( status.HTTP_503_SERVICE_UNAVAILABLE, {'detail': 'request was throttled'}) - class BasePermission(object): """ A base class from which all permission classes should inherit. @@ -88,37 +89,131 @@ class IsUserOrIsAnonReadOnly(BasePermission): raise _403_FORBIDDEN_RESPONSE -class PerUserThrottling(BasePermission): +class BaseThrottle(BasePermission): """ - Rate throttling of requests on a per-user basis. + Rate throttling of requests. - The rate (requests / seconds) is set by a :attr:`throttle` attribute on the ``View`` class. - The attribute is a two tuple of the form (number of requests, duration in seconds). + The rate (requests / seconds) is set by a :attr:`throttle` attribute + on the :class:`.View` class. The attribute is a string of the form 'number of + requests/period'. - The user id will be used as a unique identifier if the user is authenticated. - For anonymous requests, the IP address of the client will be used. + Period should be one of: ('s', 'sec', 'm', 'min', 'h', 'hour', 'd', 'day') Previous request information used for throttling is stored in the cache. + """ + + attr_name = 'throttle' + default = '0/sec' + timer = time.time + + def get_cache_key(self): + """ + Should return a unique cache-key which can be used for throttling. + Muse be overridden. + """ + pass + + def check_permission(self, auth): + """ + Check the throttling. + Return `None` or raise an :exc:`.ErrorResponse`. + """ + num, period = getattr(self.view, self.attr_name, self.default).split('/') + self.num_requests = int(num) + self.duration = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}[period[0]] + self.auth = auth + self.check_throttle() + + def check_throttle(self): + """ + Implement the check to see if the request should be throttled. + + On success calls :meth:`throttle_success`. + On failure calls :meth:`throttle_failure`. + """ + self.key = self.get_cache_key() + self.history = cache.get(self.key, []) + self.now = self.timer() + + # Drop any requests from the history which have now passed the + # throttle duration + while self.history and self.history[-1] <= self.now - self.duration: + self.history.pop() + if len(self.history) >= self.num_requests: + self.throttle_failure() + else: + self.throttle_success() + + def throttle_success(self): + """ + Inserts the current request's timestamp along with the key + into the cache. + """ + self.history.insert(0, self.now) + cache.set(self.key, self.history, self.duration) + header = 'status=SUCCESS; next=%s sec' % self.next() + self.view.add_header('X-Throttle', header) + + def throttle_failure(self): + """ + Called when a request to the API has failed due to throttling. + Raises a '503 service unavailable' response. + """ + header = 'status=FAILURE; next=%s sec' % self.next() + self.view.add_header('X-Throttle', header) + raise _503_SERVICE_UNAVAILABLE + + def next(self): + """ + Returns the recommended next request time in seconds. + """ + if self.history: + remaining_duration = self.duration - (self.now - self.history[-1]) + else: + remaining_duration = self.duration + + available_requests = self.num_requests - len(self.history) + 1 + + return '%.2f' % (remaining_duration / float(available_requests)) + + +class PerUserThrottling(BaseThrottle): """ + Limits the rate of API calls that may be made by a given user. - def check_permission(self, user): - (num_requests, duration) = getattr(self.view, 'throttle', (0, 0)) + The user id will be used as a unique identifier if the user is + authenticated. For anonymous requests, the IP address of the client will + be used. + """ - if user.is_authenticated(): - ident = str(auth) + def get_cache_key(self): + if self.auth.is_authenticated(): + ident = str(self.auth) else: ident = self.view.request.META.get('REMOTE_ADDR', None) + return 'throttle_user_%s' % ident - key = 'throttle_%s' % ident - history = cache.get(key, []) - now = time.time() - - # Drop any requests from the history which have now passed the throttle duration - while history and history[0] < now - duration: - history.pop() - if len(history) >= num_requests: - raise _503_THROTTLED_RESPONSE +class PerViewThrottling(BaseThrottle): + """ + Limits the rate of API calls that may be used on a given view. + + The class name of the view is used as a unique identifier to + throttle against. + """ + + def get_cache_key(self): + return 'throttle_view_%s' % self.view.__class__.__name__ + + +class PerResourceThrottling(BaseThrottle): + """ + Limits the rate of API calls that may be used against all views on + a given resource. + + The class name of the resource is used as a unique identifier to + throttle against. + """ - history.insert(0, now) - cache.set(key, history, duration) + def get_cache_key(self): + return 'throttle_resource_%s' % self.view.resource.__class__.__name__ diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 9834ba5e..13cd52f5 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -17,6 +17,7 @@ from djangorestframework.utils import dict2xml, url_resolves from djangorestframework.utils.breadcrumbs import get_breadcrumbs from djangorestframework.utils.description import get_name, get_description from djangorestframework.utils.mediatypes import get_media_type_params, add_media_type_param, media_type_matches +from djangorestframework import VERSION from decimal import Decimal import re @@ -108,6 +109,7 @@ class XMLRenderer(BaseRenderer): """ Renderer which serializes to XML. """ + media_type = 'application/xml' def render(self, obj=None, media_type=None): @@ -181,7 +183,7 @@ class DocumentingTemplateRenderer(BaseRenderer): # Get the form instance if we have one bound to the input form_instance = None - if method == view.method.lower(): + if method == getattr(view, 'method', view.request.method).lower(): form_instance = getattr(view, 'bound_form_instance', None) if not form_instance and hasattr(view, 'get_bound_form'): @@ -251,6 +253,7 @@ class DocumentingTemplateRenderer(BaseRenderer): The context used in the template contains all the information needed to self-document the response to this request. """ + content = self._get_content(self.view, self.view.request, obj, media_type) put_form_instance = self._get_form_instance(self.view, 'put') @@ -283,6 +286,7 @@ class DocumentingTemplateRenderer(BaseRenderer): 'response': self.view.response, 'description': description, 'name': name, + 'version': VERSION, 'markeddown': markeddown, 'breadcrumblist': breadcrumb_list, 'available_media_types': self.view._rendered_media_types, diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index adf5c1c3..08f9e0ae 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -6,6 +6,7 @@ from django.db.models.fields.related import RelatedField from django.utils.encoding import smart_unicode from djangorestframework.response import ErrorResponse +from djangorestframework.serializer import Serializer from djangorestframework.utils import as_tuple import decimal @@ -13,105 +14,9 @@ import inspect import re -# TODO: _IgnoreFieldException -# Map model classes to resource classes -#_model_to_resource = {} - -def _model_to_dict(instance, resource=None): - """ - Given a model instance, return a ``dict`` representing the model. - - The implementation is similar to Django's ``django.forms.model_to_dict``, except: - - * It doesn't coerce related objects into primary keys. - * It doesn't drop ``editable=False`` fields. - * It also supports attribute or method fields on the instance or resource. - """ - opts = instance._meta - data = {} - - #print [rel.name for rel in opts.get_all_related_objects()] - #related = [rel.get_accessor_name() for rel in opts.get_all_related_objects()] - #print [getattr(instance, rel) for rel in related] - #if resource.fields: - # fields = resource.fields - #else: - # fields = set(opts.fields + opts.many_to_many) - - fields = resource and resource.fields or () - include = resource and resource.include or () - exclude = resource and resource.exclude or () - - extra_fields = fields and list(resource.fields) or [] - - # Model fields - for f in opts.fields + opts.many_to_many: - 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) - - if extra_fields and f.name in extra_fields: - extra_fields.remove(f.name) - - # Method fields - for fname in extra_fields: - if hasattr(resource, fname): - # check the resource first, to allow it to override fields - obj = getattr(resource, fname) - # if it's a method like foo(self, instance), then call it - if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 2: - obj = obj(instance) - elif hasattr(instance, fname): - # now check the object instance - obj = getattr(instance, fname) - else: - continue - - # TODO: It would be nicer if this didn't recurse here. - # Let's keep _model_to_dict flat, and _object_to_data recursive. - data[fname] = _object_to_data(obj) - - return data - - -def _object_to_data(obj, resource=None): - """ - Convert an object into a serializable representation. - """ - if isinstance(obj, dict): - # dictionaries - # TODO: apply same _model_to_dict logic fields/exclude here - 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, resource) for item in obj] - if isinstance(obj, models.Manager): - # Manager objects - return [_object_to_data(item, resource) for item in obj.all()] - if isinstance(obj, models.Model): - # Model instances - return _object_to_data(_model_to_dict(obj, resource)) - 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(), resource) - if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) <= 1: - # bound method - return _object_to_data(obj(), resource) - - return smart_unicode(obj, strings_only=True) - - -class BaseResource(object): +class BaseResource(Serializer): """ Base class for all Resource classes, which simply defines the interface they provide. """ @@ -119,7 +24,8 @@ class BaseResource(object): include = None exclude = None - def __init__(self, view): + def __init__(self, view=None, depth=None, stack=[], **kwargs): + super(BaseResource, self).__init__(depth, stack, **kwargs) self.view = view def validate_request(self, data, files=None): @@ -133,7 +39,7 @@ class BaseResource(object): """ Given the response content, filter it into a serializable object. """ - return _object_to_data(obj, self) + return self.serialize(obj) class Resource(BaseResource): @@ -223,15 +129,13 @@ class FormResource(Resource): # In addition to regular validation we also ensure no additional fields are being passed in... unknown_fields = seen_fields_set - (form_fields_set | allowed_extra_fields_set) - unknown_fields = unknown_fields - set(('csrfmiddlewaretoken', '_accept')) # TODO: Ugh. - + unknown_fields = unknown_fields - set(('csrfmiddlewaretoken', '_accept', '_method')) # TODO: Ugh. + # Check using both regular validation, and our stricter no additional fields rule if bound_form.is_valid() and not unknown_fields: # Validation succeeded... cleaned_data = bound_form.cleaned_data - cleaned_data.update(bound_form.files) - # Add in any extra fields to the cleaned content... for key in (allowed_extra_fields_set & seen_fields_set) - set(cleaned_data.keys()): cleaned_data[key] = data[key] @@ -282,7 +186,7 @@ class FormResource(Resource): """ # A form on the view overrides a form on the resource. - form = getattr(self.view, 'form', self.form) + form = getattr(self.view, 'form', None) or self.form # Use the requested method or determine the request method if method is None and hasattr(self.view, 'request') and hasattr(self.view, 'method'): @@ -299,7 +203,7 @@ class FormResource(Resource): if not form: return None - if data is not None: + if data is not None or files is not None: return form(data, files) return form() @@ -375,8 +279,8 @@ class ModelResource(FormResource): """ super(ModelResource, self).__init__(view) - if getattr(view, 'model', None): - self.model = view.model + self.model = getattr(view, 'model', None) or self.model + def validate_request(self, data, files=None): """ @@ -437,6 +341,9 @@ class ModelResource(FormResource): This method can be overridden if you need to set the resource url reversing explicitly. """ + if not hasattr(self, 'view_callable'): + raise NoReverseMatch + # dis does teh magicks... urlconf = get_urlconf() resolver = get_resolver(urlconf) @@ -458,7 +365,7 @@ class ModelResource(FormResource): if isinstance(attr, models.Model): instance_attrs[param] = attr.pk else: - instance_attrs[param] = attr + instance_attrs[param] = attr try: return reverse(self.view_callable[0], kwargs=instance_attrs) diff --git a/djangorestframework/runtests/runtests.py b/djangorestframework/runtests/runtests.py index a3cdfa67..1da918f5 100644 --- a/djangorestframework/runtests/runtests.py +++ b/djangorestframework/runtests/runtests.py @@ -13,21 +13,27 @@ os.environ['DJANGO_SETTINGS_MODULE'] = 'djangorestframework.runtests.settings' from django.conf import settings from django.test.utils import get_runner +def usage(): + return """ + Usage: python runtests.py [UnitTestClass].[method] + + You can pass the Class name of the `UnitTestClass` you want to test. + + Append a method name if you only want to test a specific method of that class. + """ + def main(): TestRunner = get_runner(settings) - if hasattr(TestRunner, 'func_name'): - # Pre 1.2 test runners were just functions, - # and did not support the 'failfast' option. - import warnings - warnings.warn( - 'Function-based test runners are deprecated. Test runners should be classes with a run_tests() method.', - DeprecationWarning - ) - failures = TestRunner(['djangorestframework']) + test_runner = TestRunner() + if len(sys.argv) == 2: + test_case = '.' + sys.argv[1] + elif len(sys.argv) == 1: + test_case = '' else: - test_runner = TestRunner() - failures = test_runner.run_tests(['djangorestframework']) + print usage() + sys.exit(1) + failures = test_runner.run_tests(['djangorestframework' + test_case]) sys.exit(failures) diff --git a/djangorestframework/serializer.py b/djangorestframework/serializer.py new file mode 100644 index 00000000..da8036e9 --- /dev/null +++ b/djangorestframework/serializer.py @@ -0,0 +1,310 @@ +""" +Customizable serialization. +""" +from django.db import models +from django.db.models.query import QuerySet +from django.db.models.fields.related import RelatedField +from django.utils.encoding import smart_unicode, is_protected_type + +import decimal +import inspect +import types + + +# We register serializer classes, so that we can refer to them by their +# class names, if there are cyclical serialization heirachys. +_serializers = {} + + +def _field_to_tuple(field): + """ + Convert an item in the `fields` attribute into a 2-tuple. + """ + if isinstance(field, (tuple, list)): + return (field[0], field[1]) + return (field, None) + +def _fields_to_list(fields): + """ + Return a list of field names. + """ + return [_field_to_tuple(field)[0] for field in fields or ()] + +def _fields_to_dict(fields): + """ + Return a `dict` of field name -> None, or tuple of fields, or Serializer class + """ + return dict([_field_to_tuple(field) for field in fields or ()]) + + +class _SkipField(Exception): + """ + Signals that a serialized field should be ignored. + We use this mechanism as the default behavior for ensuring + that we don't infinitely recurse when dealing with nested data. + """ + pass + + +class _RegisterSerializer(type): + """ + Metaclass to register serializers. + """ + def __new__(cls, name, bases, attrs): + # Build the class and register it. + ret = super(_RegisterSerializer, cls).__new__(cls, name, bases, attrs) + _serializers[name] = ret + return ret + + +class Serializer(object): + """ + Converts python objects into plain old native types suitable for + serialization. In particular it handles models and querysets. + + The output format is specified by setting a number of attributes + on the class. + + You may also override any of the serialization methods, to provide + for more flexible behavior. + + Valid output types include anything that may be directly rendered into + json, xml etc... + """ + __metaclass__ = _RegisterSerializer + + fields = () + """ + Specify the fields to be serialized on a model or dict. + Overrides `include` and `exclude`. + """ + + include = () + """ + Fields to add to the default set to be serialized on a model/dict. + """ + + exclude = () + """ + Fields to remove from the default set to be serialized on a model/dict. + """ + + rename = {} + """ + A dict of key->name to use for the field keys. + """ + + related_serializer = None + """ + The default serializer class to use for any related models. + """ + + depth = None + """ + The maximum depth to serialize to, or `None`. + """ + + + def __init__(self, depth=None, stack=[], **kwargs): + self.depth = depth or self.depth + self.stack = stack + + + def get_fields(self, obj): + """ + Return the set of field names/keys to use for a model instance/dict. + """ + fields = self.fields + + # If `fields` is not set, we use the default fields and modify + # them with `include` and `exclude` + if not fields: + default = self.get_default_fields(obj) + include = self.include or () + exclude = self.exclude or () + fields = set(default + list(include)) - set(exclude) + + else: + fields = _fields_to_list(self.fields) + + return fields + + + def get_default_fields(self, obj): + """ + Return the default list of field names/keys for a model instance/dict. + These are used if `fields` is not given. + """ + if isinstance(obj, models.Model): + opts = obj._meta + return [field.name for field in opts.fields + opts.many_to_many] + else: + return obj.keys() + + + def get_related_serializer(self, key): + info = _fields_to_dict(self.fields).get(key, None) + + # If an element in `fields` is a 2-tuple of (str, tuple) + # then the second element of the tuple is the fields to + # set on the related serializer + if isinstance(info, (list, tuple)): + class OnTheFlySerializer(Serializer): + fields = info + return OnTheFlySerializer + + # If an element in `fields` is a 2-tuple of (str, Serializer) + # then the second element of the tuple is the Serializer + # class to use for that field. + elif isinstance(info, type) and issubclass(info, Serializer): + return info + + # If an element in `fields` is a 2-tuple of (str, str) + # then the second element of the tuple is the name of the Serializer + # class to use for that field. + # + # Black magic to deal with cyclical Serializer dependancies. + # Similar to what Django does for cyclically related models. + elif isinstance(info, str) and info in _serializers: + return _serializers[info] + + # Otherwise use `related_serializer` or fall back to `Serializer` + return getattr(self, 'related_serializer') or Serializer + + + def serialize_key(self, key): + """ + Keys serialize to their string value, + unless they exist in the `rename` dict. + """ + return getattr(self.rename, key, key) + + + def serialize_val(self, key, obj): + """ + Convert a model field or dict value into a serializable representation. + """ + related_serializer = self.get_related_serializer(key) + + if self.depth is None: + depth = None + elif self.depth <= 0: + return self.serialize_max_depth(obj) + else: + depth = self.depth - 1 + + if any([obj is elem for elem in self.stack]): + return self.serialize_recursion(obj) + else: + stack = self.stack[:] + stack.append(obj) + + return related_serializer(depth=depth, stack=stack).serialize(obj) + + + def serialize_max_depth(self, obj): + """ + Determine how objects should be serialized once `depth` is exceeded. + The default behavior is to ignore the field. + """ + raise _SkipField + + + def serialize_recursion(self, obj): + """ + Determine how objects should be serialized if recursion occurs. + The default behavior is to ignore the field. + """ + raise _SkipField + + + def serialize_model(self, instance): + """ + Given a model instance or dict, serialize it to a dict.. + """ + data = {} + + fields = self.get_fields(instance) + + # serialize each required field + for fname in fields: + if hasattr(self, fname): + # check for a method 'fname' on self first + meth = getattr(self, fname) + if inspect.ismethod(meth) and len(inspect.getargspec(meth)[0]) == 2: + obj = meth(instance) + elif hasattr(instance, fname): + # now check for an attribute 'fname' on the instance + obj = getattr(instance, fname) + elif fname in instance: + # finally check for a key 'fname' on the instance + obj = instance[fname] + else: + continue + + try: + key = self.serialize_key(fname) + val = self.serialize_val(fname, obj) + data[key] = val + except _SkipField: + pass + + return data + + + def serialize_iter(self, obj): + """ + Convert iterables into a serializable representation. + """ + return [self.serialize(item) for item in obj] + + + def serialize_func(self, obj): + """ + Convert no-arg methods and functions into a serializable representation. + """ + return self.serialize(obj()) + + + def serialize_manager(self, obj): + """ + Convert a model manager into a serializable representation. + """ + return self.serialize_iter(obj.all()) + + + def serialize_fallback(self, obj): + """ + Convert any unhandled object into a serializable representation. + """ + return smart_unicode(obj, strings_only=True) + + + def serialize(self, obj): + """ + Convert any object into a serializable representation. + """ + + if isinstance(obj, (dict, models.Model)): + # Model instances & dictionaries + return self.serialize_model(obj) + elif isinstance(obj, (tuple, list, set, QuerySet, types.GeneratorType)): + # basic iterables + return self.serialize_iter(obj) + elif isinstance(obj, models.Manager): + # Manager objects + return self.serialize_manager(obj) + elif inspect.isfunction(obj) and not inspect.getargspec(obj)[0]: + # function with no args + return self.serialize_func(obj) + elif inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) <= 1: + # bound method + return self.serialize_func(obj) + + # Protected types are passed through as is. + # (i.e. Primitives like None, numbers, dates, and Decimals.) + if is_protected_type(obj): + return obj + + # All other values are converted to string. + return self.serialize_fallback(obj) diff --git a/djangorestframework/templates/renderer.html b/djangorestframework/templates/renderer.html index 94748d28..97d3837a 100644 --- a/djangorestframework/templates/renderer.html +++ b/djangorestframework/templates/renderer.html @@ -18,7 +18,7 @@ <div id="header"> <div id="branding"> - <h1 id="site-name"><a href='http://django-rest-framework.org'>Django REST framework</a></h1> + <h1 id="site-name"><a href='http://django-rest-framework.org'>Django REST framework</a> <small>{{ version }}</small></h1> </div> <div id="user-tools"> {% if user.is_active %}Welcome, {{ user }}.{% if logout_url %} <a href='{{ logout_url }}'>Log out</a>{% endif %}{% else %}Anonymous {% if login_url %}<a href='{{ login_url }}'>Log in</a>{% endif %}{% endif %} diff --git a/djangorestframework/tests/files.py b/djangorestframework/tests/files.py index 25aad9b4..992d3cba 100644 --- a/djangorestframework/tests/files.py +++ b/djangorestframework/tests/files.py @@ -12,20 +12,16 @@ class UploadFilesTests(TestCase): def test_upload_file(self): - class FileForm(forms.Form): - file = forms.FileField - - class MockResource(FormResource): - form = FileForm + file = forms.FileField() class MockView(View): permissions = () - resource = MockResource + form = FileForm def post(self, request, *args, **kwargs): - return {'FILE_NAME': self.CONTENT['file'][0].name, - 'FILE_CONTENT': self.CONTENT['file'][0].read()} + return {'FILE_NAME': self.CONTENT['file'].name, + 'FILE_CONTENT': self.CONTENT['file'].read()} file = StringIO.StringIO('stuff') file.name = 'stuff.txt' diff --git a/djangorestframework/tests/parsers.py b/djangorestframework/tests/parsers.py index 3ab1a61c..deba688e 100644 --- a/djangorestframework/tests/parsers.py +++ b/djangorestframework/tests/parsers.py @@ -131,3 +131,25 @@ # self.assertEqual(data['key1'], 'val1') # self.assertEqual(files['file1'].read(), 'blablabla') +from StringIO import StringIO +from cgi import parse_qs +from django import forms +from django.test import TestCase +from djangorestframework.parsers import FormParser + +class Form(forms.Form): + field1 = forms.CharField(max_length=3) + field2 = forms.CharField() + +class TestFormParser(TestCase): + def setUp(self): + self.string = "field1=abc&field2=defghijk" + + def test_parse(self): + """ Make sure the `QueryDict` works OK """ + parser = FormParser(None) + + stream = StringIO(self.string) + (data, files) = parser.parse(stream) + + self.assertEqual(Form(data).is_valid(), True) diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py index c9108764..569eb640 100644 --- a/djangorestframework/tests/renderers.py +++ b/djangorestframework/tests/renderers.py @@ -1,12 +1,16 @@ from django.conf.urls.defaults import patterns, url from django import http from django.test import TestCase + from djangorestframework.compat import View as DjangoView from djangorestframework.renderers import BaseRenderer, JSONRenderer +from djangorestframework.parsers import JSONParser from djangorestframework.mixins import ResponseMixin from djangorestframework.response import Response from djangorestframework.utils.mediatypes import add_media_type_param +from StringIO import StringIO + DUMMYSTATUS = 200 DUMMYCONTENT = 'dummycontent' @@ -100,14 +104,35 @@ class JSONRendererTests(TestCase): """ Tests specific to the JSON Renderer """ + def test_without_content_type_args(self): + """ + Test basic JSON rendering. + """ obj = {'foo':['bar','baz']} renderer = JSONRenderer(None) content = renderer.render(obj, 'application/json') self.assertEquals(content, _flat_repr) def test_with_content_type_args(self): + """ + Test JSON rendering with additional content type arguments supplied. + """ obj = {'foo':['bar','baz']} renderer = JSONRenderer(None) content = renderer.render(obj, 'application/json; indent=2') self.assertEquals(content, _indented_repr) + + def test_render_and_parse(self): + """ + Test rendering and then parsing returns the original object. + IE obj -> render -> parse -> obj. + """ + obj = {'foo':['bar','baz']} + + renderer = JSONRenderer(None) + parser = JSONParser(None) + + content = renderer.render(obj, 'application/json') + (data, files) = parser.parse(StringIO(content)) + self.assertEquals(obj, data) diff --git a/djangorestframework/tests/resources.py b/djangorestframework/tests/resources.py deleted file mode 100644 index fd1226be..00000000 --- a/djangorestframework/tests/resources.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Tests for the resource module""" -from django.test import TestCase -from djangorestframework.resources 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)
\ No newline at end of file diff --git a/djangorestframework/tests/serializer.py b/djangorestframework/tests/serializer.py new file mode 100644 index 00000000..9f629050 --- /dev/null +++ b/djangorestframework/tests/serializer.py @@ -0,0 +1,117 @@ +"""Tests for the resource module""" +from django.test import TestCase +from djangorestframework.serializer import Serializer + +from django.db import models + +import datetime +import decimal + +class TestObjectToData(TestCase): + """ + Tests for the Serializer class. + """ + + def setUp(self): + self.serializer = Serializer() + self.serialize = self.serializer.serialize + + def test_decimal(self): + """Decimals need to be converted to a string representation.""" + self.assertEquals(self.serialize(decimal.Decimal('1.5')), decimal.Decimal('1.5')) + + def test_function(self): + """Functions with no arguments should be called.""" + def foo(): + return 1 + self.assertEquals(self.serialize(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(self.serialize(Foo().foo), 1) + + def test_datetime(self): + """ + datetime objects are left as-is. + """ + now = datetime.datetime.now() + self.assertEquals(self.serialize(now), now) + + +class TestFieldNesting(TestCase): + """ + Test nesting the fields in the Serializer class + """ + def setUp(self): + self.serializer = Serializer() + self.serialize = self.serializer.serialize + + class M1(models.Model): + field1 = models.CharField() + field2 = models.CharField() + + class M2(models.Model): + field = models.OneToOneField(M1) + + class M3(models.Model): + field = models.ForeignKey(M1) + + self.m1 = M1(field1='foo', field2='bar') + self.m2 = M2(field=self.m1) + self.m3 = M3(field=self.m1) + + + def test_tuple_nesting(self): + """ + Test tuple nesting on `fields` attr + """ + class SerializerM2(Serializer): + fields = (('field', ('field1',)),) + + class SerializerM3(Serializer): + fields = (('field', ('field2',)),) + + self.assertEqual(SerializerM2().serialize(self.m2), {'field': {'field1': u'foo'}}) + self.assertEqual(SerializerM3().serialize(self.m3), {'field': {'field2': u'bar'}}) + + + def test_serializer_class_nesting(self): + """ + Test related model serialization + """ + class NestedM2(Serializer): + fields = ('field1', ) + + class NestedM3(Serializer): + fields = ('field2', ) + + class SerializerM2(Serializer): + fields = [('field', NestedM2)] + + class SerializerM3(Serializer): + fields = [('field', NestedM3)] + + self.assertEqual(SerializerM2().serialize(self.m2), {'field': {'field1': u'foo'}}) + self.assertEqual(SerializerM3().serialize(self.m3), {'field': {'field2': u'bar'}}) + + def test_serializer_classname_nesting(self): + """ + Test related model serialization + """ + class SerializerM2(Serializer): + fields = [('field', 'NestedM2')] + + class SerializerM3(Serializer): + fields = [('field', 'NestedM3')] + + class NestedM2(Serializer): + fields = ('field1', ) + + class NestedM3(Serializer): + fields = ('field2', ) + + self.assertEqual(SerializerM2().serialize(self.m2), {'field': {'field1': u'foo'}}) + self.assertEqual(SerializerM3().serialize(self.m3), {'field': {'field2': u'bar'}}) diff --git a/djangorestframework/tests/throttling.py b/djangorestframework/tests/throttling.py index a8f08b18..b620ee24 100644 --- a/djangorestframework/tests/throttling.py +++ b/djangorestframework/tests/throttling.py @@ -1,38 +1,148 @@ -from django.conf.urls.defaults import patterns +""" +Tests for the throttling implementations in the permissions module. +""" + from django.test import TestCase -from django.utils import simplejson as json +from django.contrib.auth.models import User +from django.core.cache import cache from djangorestframework.compat import RequestFactory from djangorestframework.views import View -from djangorestframework.permissions import PerUserThrottling - +from djangorestframework.permissions import PerUserThrottling, PerViewThrottling, PerResourceThrottling +from djangorestframework.resources import FormResource class MockView(View): permissions = ( PerUserThrottling, ) - throttle = (3, 1) # 3 requests per second + throttle = '3/sec' def get(self, request): return 'foo' -urlpatterns = patterns('', - (r'^$', MockView.as_view()), -) - - -#class ThrottlingTests(TestCase): -# """Basic authentication""" -# urls = 'djangorestframework.tests.throttling' -# -# def test_requests_are_throttled(self): -# """Ensure request rate is limited""" -# for dummy in range(3): -# response = self.client.get('/') -# response = self.client.get('/') -# -# def test_request_throttling_is_per_user(self): -# """Ensure request rate is only limited per user, not globally""" -# pass -# -# def test_request_throttling_expires(self): -# """Ensure request rate is limited for a limited duration only""" -# pass +class MockView_PerViewThrottling(MockView): + permissions = ( PerViewThrottling, ) + +class MockView_PerResourceThrottling(MockView): + permissions = ( PerResourceThrottling, ) + resource = FormResource + +class MockView_MinuteThrottling(MockView): + throttle = '3/min' + + + +class ThrottlingTests(TestCase): + urls = 'djangorestframework.tests.throttling' + + def setUp(self): + """ + Reset the cache so that no throttles will be active + """ + cache.clear() + self.factory = RequestFactory() + + def test_requests_are_throttled(self): + """ + Ensure request rate is limited + """ + request = self.factory.get('/') + for dummy in range(4): + response = MockView.as_view()(request) + self.assertEqual(503, response.status_code) + + def set_throttle_timer(self, view, value): + """ + Explicitly set the timer, overriding time.time() + """ + view.permissions[0].timer = lambda self: value + + def test_request_throttling_expires(self): + """ + Ensure request rate is limited for a limited duration only + """ + self.set_throttle_timer(MockView, 0) + + request = self.factory.get('/') + for dummy in range(4): + response = MockView.as_view()(request) + self.assertEqual(503, response.status_code) + + # Advance the timer by one second + self.set_throttle_timer(MockView, 1) + + response = MockView.as_view()(request) + self.assertEqual(200, response.status_code) + + def ensure_is_throttled(self, view, expect): + request = self.factory.get('/') + request.user = User.objects.create(username='a') + for dummy in range(3): + view.as_view()(request) + request.user = User.objects.create(username='b') + response = view.as_view()(request) + self.assertEqual(expect, response.status_code) + + def test_request_throttling_is_per_user(self): + """ + Ensure request rate is only limited per user, not globally for + PerUserThrottles + """ + self.ensure_is_throttled(MockView, 200) + + def test_request_throttling_is_per_view(self): + """ + Ensure request rate is limited globally per View for PerViewThrottles + """ + self.ensure_is_throttled(MockView_PerViewThrottling, 503) + + def test_request_throttling_is_per_resource(self): + """ + Ensure request rate is limited globally per Resource for PerResourceThrottles + """ + self.ensure_is_throttled(MockView_PerResourceThrottling, 503) + + + def ensure_response_header_contains_proper_throttle_field(self, view, expected_headers): + """ + Ensure the response returns an X-Throttle field with status and next attributes + set properly. + """ + request = self.factory.get('/') + for timer, expect in expected_headers: + self.set_throttle_timer(view, timer) + response = view.as_view()(request) + self.assertEquals(response['X-Throttle'], expect) + + def test_seconds_fields(self): + """ + Ensure for second based throttles. + """ + self.ensure_response_header_contains_proper_throttle_field(MockView, + ((0, 'status=SUCCESS; next=0.33 sec'), + (0, 'status=SUCCESS; next=0.50 sec'), + (0, 'status=SUCCESS; next=1.00 sec'), + (0, 'status=FAILURE; next=1.00 sec') + )) + + def test_minutes_fields(self): + """ + Ensure for minute based throttles. + """ + self.ensure_response_header_contains_proper_throttle_field(MockView_MinuteThrottling, + ((0, 'status=SUCCESS; next=20.00 sec'), + (0, 'status=SUCCESS; next=30.00 sec'), + (0, 'status=SUCCESS; next=60.00 sec'), + (0, 'status=FAILURE; next=60.00 sec') + )) + + def test_next_rate_remains_constant_if_followed(self): + """ + If a client follows the recommended next request rate, + the throttling rate should stay constant. + """ + self.ensure_response_header_contains_proper_throttle_field(MockView_MinuteThrottling, + ((0, 'status=SUCCESS; next=20.00 sec'), + (20, 'status=SUCCESS; next=20.00 sec'), + (40, 'status=SUCCESS; next=20.00 sec'), + (60, 'status=SUCCESS; next=20.00 sec'), + (80, 'status=SUCCESS; next=20.00 sec') + )) diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 5b3cc855..18d064e1 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -56,15 +56,15 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): """ List of all authenticating methods to attempt. """ - authentication = ( authentication.UserLoggedInAuthenticaton, - authentication.BasicAuthenticaton ) + authentication = ( authentication.UserLoggedInAuthentication, + authentication.BasicAuthentication ) """ List of all permissions that must be checked. """ permissions = ( permissions.FullAnonAccess, ) - + @classmethod def as_view(cls, **initkwargs): """ @@ -101,6 +101,14 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): """ pass + + def add_header(self, field, value): + """ + Add *field* and *value* to the :attr:`headers` attribute of the :class:`View` class. + """ + self.headers[field] = value + + # Note: session based authentication is explicitly CSRF validated, # all other authentication is CSRF exempt. @csrf_exempt @@ -108,6 +116,7 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): self.request = request self.args = args self.kwargs = kwargs + self.headers = {} # Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here. prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host()) @@ -149,7 +158,10 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): # also it's currently sub-obtimal for HTTP caching - need to sort that out. response.headers['Allow'] = ', '.join(self.allowed_methods) response.headers['Vary'] = 'Authenticate, Accept' - + + # merge with headers possibly set at some point in the view + response.headers.update(self.headers) + return self.render(response) diff --git a/docs/examples/blogpost.rst b/docs/examples/blogpost.rst index 9d762f52..be91913d 100644 --- a/docs/examples/blogpost.rst +++ b/docs/examples/blogpost.rst @@ -8,26 +8,32 @@ Blog Posts API The models ---------- +In this example we're working from two related models: + ``models.py`` .. include:: ../../examples/blogpost/models.py :literal: -URL configuration ------------------ +Creating the resources +---------------------- + +We need to create two resources that we map to our two existing models, in order to describe how the models should be serialized. +Our resource descriptions will typically go into a module called something like 'resources.py' -``urls.py`` +``resources.py`` -.. include:: ../../examples/blogpost/urls.py +.. include:: ../../examples/blogpost/resources.py :literal: -Creating the resources ----------------------- +Creating views for our resources +-------------------------------- -Once we have some existing models there's very little we need to do to create the corresponding resources. We simply create a base resource and an instance resource for each model we're working with. -django-rest-framework will provide the default operations on the resources all the usual input validation that Django's models can give us for free. +Once we've created the resources there's very little we need to do to create the API. +For each resource we'll create a base view, and an instance view. +The generic views :class:`.ListOrCreateModelView` and :class:`.InstanceModelView` provide default operations for listing, creating and updating our models via the API, and also automatically provide input validation using default ModelForms for each model. -#``views.py`` +``urls.py`` -#.. include:: ../../examples/blogpost/views.py -# :literal:
\ No newline at end of file +.. include:: ../../examples/blogpost/urls.py + :literal: diff --git a/docs/examples/modelresources.rst b/docs/examples/modelviews.rst index 676656a7..c60c9f24 100644 --- a/docs/examples/modelresources.rst +++ b/docs/examples/modelviews.rst @@ -1,7 +1,7 @@ -.. _modelresources: +.. _modelviews: -Getting Started - Model Resources ---------------------------------- +Getting Started - Model Views +----------------------------- .. note:: @@ -15,28 +15,28 @@ Getting Started - Model Resources Often you'll want parts of your API to directly map to existing django models. Django REST framework handles this nicely for you in a couple of ways: -#. It automatically provides suitable create/read/update/delete methods for your resources. +#. It automatically provides suitable create/read/update/delete methods for your views. #. Input validation occurs automatically, by using appropriate `ModelForms <http://docs.djangoproject.com/en/dev/topics/forms/modelforms/>`_. -We'll start of defining two resources in our urlconf again. +Here's the model we're working from in this example: -``urls.py`` +``models.py`` -.. include:: ../../examples/modelresourceexample/urls.py +.. include:: ../../examples/modelresourceexample/models.py :literal: -Here's the models we're working from in this example. It's usually a good idea to make sure you provide the :func:`get_absolute_url()` `permalink <http://docs.djangoproject.com/en/dev/ref/models/instances/#get-absolute-url>`_ for all models you want to expose via the API. +To add an API for the model, first we need to create a Resource for the model. -``models.py`` +``resources.py`` -.. include:: ../../examples/modelresourceexample/models.py +.. include:: ../../examples/modelresourceexample/resources.py :literal: -Now that we've got some models and a urlconf, there's very little code to write. We'll create a :class:`.ModelResource` to map to instances of our models, and a top level :class:`.RootModelResource` to list the existing instances and to create new instances. +Then we simply map a couple of views to the Resource in our urlconf. -``views.py`` +``urls.py`` -.. include:: ../../examples/modelresourceexample/views.py +.. include:: ../../examples/modelresourceexample/urls.py :literal: And we're done. We've now got a fully browseable API, which supports multiple input and output media types, and has all the nice automatic field validation that Django gives us for free. @@ -56,5 +56,3 @@ Or access it from the command line using curl: # Demonstrates API's input validation using JSON input bash: curl -X POST -H 'Content-Type: application/json' --data-binary '{"foo":true}' http://api.django-rest-framework.org/model-resource-example/ {"detail": {"bar": ["This field is required."], "baz": ["This field is required."]}} - -We could also have added the handler methods :meth:`.Resource.get()`, :meth:`.Resource.post()` etc... seen in the last example, but Django REST framework provides nice default implementations for us that do exactly what we'd expect them to. diff --git a/docs/examples/resources.rst b/docs/examples/views.rst index f3242421..59e13976 100644 --- a/docs/examples/resources.rst +++ b/docs/examples/views.rst @@ -1,7 +1,7 @@ -.. _resources: +.. _views: -Getting Started - Resources ---------------------------- +Getting Started - Views +----------------------- .. note:: @@ -15,12 +15,12 @@ Getting Started - Resources We're going to start off with a simple example, that demonstrates a few things: -#. Creating resources. -#. Linking resources. -#. Writing method handlers on resources. -#. Adding form validation to resources. +#. Creating views. +#. Linking views. +#. Writing method handlers on views. +#. Adding form validation to views. -First we'll define two resources in our urlconf. +First we'll define two views in our urlconf. ``urls.py`` @@ -34,7 +34,7 @@ Now we'll add a form that we'll use for input validation. This is completely op .. include:: ../../examples/resourceexample/forms.py :literal: -Now we'll write our resources. The first is a read only resource that links to three instances of the second. The second resource just has some stub handler methods to help us see that our example is working. +Now we'll write our views. The first is a read only view that links to three instances of the second. The second view just has some stub handler methods to help us see that our example is working. ``views.py`` diff --git a/docs/howto/alternativeframeworks.rst b/docs/howto/alternativeframeworks.rst index c6eba1dd..dc8d1ea6 100644 --- a/docs/howto/alternativeframeworks.rst +++ b/docs/howto/alternativeframeworks.rst @@ -1,6 +1,35 @@ -Alternative Frameworks -====================== +Alternative frameworks & Why Django REST framework +================================================== -#. `django-piston <https://bitbucket.org/jespern/django-piston/wiki/Home>`_ is excellent, and has a great community behind it. This project is based on piston code in parts. +Alternative frameworks +---------------------- -#. `django-tasypie <https://github.com/toastdriven/django-tastypie>`_ is also well worth looking at. +There are a number of alternative REST frameworks for Django: + +* `django-piston <https://bitbucket.org/jespern/django-piston/wiki/Home>`_ is very mature, and has a large community behind it. This project was originally based on piston code in parts. +* `django-tasypie <https://github.com/toastdriven/django-tastypie>`_ is also very good, and has a very active and helpful developer community and maintainers. +* Other interesting projects include `dagny <https://github.com/zacharyvoase/dagny>`_ and `dj-webmachine <http://benoitc.github.com/dj-webmachine/>`_ + + +Why use Django REST framework? +------------------------------ + +The big benefits of using Django REST framework come down to: + +1. It's based on Django's class based views, which makes it simple, modular, and future-proof. +2. It stays as close as possible to Django idioms and language throughout. +3. The browse-able API makes working with the APIs extremely quick and easy. + + +Why was this project created? +----------------------------- + +For me the browse-able API is the most important aspect of Django REST framework. + +I wanted to show that Web APIs could easily be made Web browse-able, +and demonstrate how much better browse-able Web APIs are to work with. + +Being able to navigate and use a Web API directly in the browser is a huge win over only having command line and programmatic +access to the API. It enables the API to be properly self-describing, and it makes it much much quicker and easier to work with. +There's no fundamental reason why the Web APIs we're creating shouldn't be able to render to HTML as well as JSON/XML/whatever, +and I really think that more Web API frameworks *in whatever language* ought to be taking a similar approach. diff --git a/docs/howto/setup.rst b/docs/howto/setup.rst index e59ea90e..b4fbc037 100644 --- a/docs/howto/setup.rst +++ b/docs/howto/setup.rst @@ -3,6 +3,13 @@ Setup ===== +Installing into site-packages +----------------------------- + +If you need to manually install Django REST framework to your ``site-packages`` directory, run the ``setup.py`` script:: + + python setup.py install + Template Loaders ---------------- diff --git a/docs/howto/usingcurl.rst b/docs/howto/usingcurl.rst index e7edfef9..2ad2c764 100644 --- a/docs/howto/usingcurl.rst +++ b/docs/howto/usingcurl.rst @@ -23,4 +23,8 @@ There are a few things that can be helpful to remember when using CURL with djan #. Or any other content type:: - curl -X PUT -H 'Content-Type: application/json' --data-binary '{"foo":"bar"}' http://example.com/my-api/some-resource/
\ No newline at end of file + curl -X PUT -H 'Content-Type: application/json' --data-binary '{"foo":"bar"}' http://example.com/my-api/some-resource/ + +#. You can use basic authentication to send the username and password:: + + curl -X GET -H 'Accept: application/json' -u <user>:<password> http://example.com/my-api/
\ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 3b4e9c49..8a285271 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,15 +30,11 @@ Resources * The ``djangorestframework`` package is `available on PyPI <http://pypi.python.org/pypi/djangorestframework>`_. * We have an active `discussion group <http://groups.google.com/group/django-rest-framework>`_ and a `project blog <http://blog.django-rest-framework.org>`_. -* Bug reports are handled on the `issue tracker <https://bitbucket.org/tomchristie/django-rest-framework/issues?sort=version>`_. -* There is a `Jenkins CI server <http://datacenter.tibold.nl/job/djangorestframework/>`_ which tracks test status and coverage reporting. (Thanks Marko!) -* Get with in touch with `@thisneonsoul <https://twitter.com/thisneonsoul>`_ on twitter. +* Bug reports are handled on the `issue tracker <https://github.com/tomchristie/django-rest-framework/issues>`_. +* There is a `Jenkins CI server <http://jenkins.tibold.nl/job/djangorestframework/>`_ which tracks test status and coverage reporting. (Thanks Marko!) Any and all questions, thoughts, bug reports and contributions are *hugely appreciated*. -We'd like for this to be a real community driven effort, so come say hi, get involved, and get forking! (See: `Forking a Bitbucket Repository -<http://confluence.atlassian.com/display/BITBUCKET/Forking+a+Bitbucket+Repository>`_, or `Fork A GitHub Repo <http://help.github.com/fork-a-repo/>`_) - Requirements ------------ @@ -46,8 +42,8 @@ Requirements * Django (1.2, 1.3 supported) -Installation & Setup --------------------- +Installation +------------ You can install Django REST framework using ``pip`` or ``easy_install``:: @@ -59,61 +55,51 @@ Or get the latest development version using mercurial or git:: hg clone https://bitbucket.org/tomchristie/django-rest-framework git clone git@github.com:tomchristie/django-rest-framework.git -Or you can download the current release: - -* `django-rest-framework-0.1.tar.gz <https://bitbucket.org/tomchristie/django-rest-framework/downloads/django-rest-framework-0.1.tar.gz>`_ -* `django-rest-framework-0.1.zip <https://bitbucket.org/tomchristie/django-rest-framework/downloads/django-rest-framework-0.1.zip>`_ - -and then install Django REST framework to your ``site-packages`` directory, by running the ``setup.py`` script:: +Or you can `download the current release <http://pypi.python.org/pypi/djangorestframework>`_. - python setup.py install +Setup +----- -**To add django-rest-framework to a Django project:** +To add Django REST framework to a Django project: * Ensure that the ``djangorestframework`` directory is on your ``PYTHONPATH``. * Add ``djangorestframework`` to your ``INSTALLED_APPS``. -For more information take a look at the :ref:`setup` section. +For more information on settings take a look at the :ref:`setup` section. Getting Started --------------- -Using Django REST framework can be as simple as adding a few lines to your urlconf and adding a `permalink <http://docs.djangoproject.com/en/dev/ref/models/instances/#get-absolute-url>`_ to your model. +Using Django REST framework can be as simple as adding a few lines to your urlconf. -`urls.py`:: +``urls.py``:: from django.conf.urls.defaults import patterns, url - from djangorestframework import ModelResource, RootModelResource - from models import MyModel + from djangorestframework.resources import ModelResource + from djangorestframework.views import ListOrCreateModelView, InstanceModelView + from myapp.models import MyModel + + class MyResource(ModelResource): + model = MyModel urlpatterns = patterns('', - url(r'^$', RootModelResource.as_view(model=MyModel)), - url(r'^(?P<pk>[^/]+)/$', ModelResource.as_view(model=MyModel), name='my-model'), - ) - -`models.py`:: - - class MyModel(models.Model): - - # (Rest of model definition...) - - @models.permalink - def get_absolute_url(self): - return ('my-model', (self.pk,)) + url(r'^$', ListOrCreateModelView.as_view(resource=MyResource)), + url(r'^(?P<pk>[^/]+)/$', InstanceModelView.as_view(resource=MyResource)), + ) Django REST framework comes with two "getting started" examples. -#. :ref:`resources` -#. :ref:`modelresources` +#. :ref:`views` +#. :ref:`modelviews` Examples -------- There are a few real world web API examples included with Django REST framework. -#. :ref:`objectstore` - Using :class:`.Resource` for resources that do not map to models. -#. :ref:`codehighlighting` - Using :class:`.Resource` with forms for input validation. -#. :ref:`blogposts` - Using :class:`.ModelResource` for resources that map directly to models. +#. :ref:`objectstore` - Using :class:`views.View` classes for APIs that do not map to models. +#. :ref:`codehighlighting` - Using :class:`views.View` classes with forms for input validation. +#. :ref:`blogposts` - Using :class:`views.ModelView` classes for APIs that map directly to models. All the examples are freely available for testing in the sandbox: @@ -148,6 +134,7 @@ Library Reference library/renderers library/resource library/response + library/serializer library/status library/views @@ -157,8 +144,8 @@ Examples Reference .. toctree:: :maxdepth: 1 - examples/resources - examples/modelresources + examples/views + examples/modelviews examples/objectstore examples/pygments examples/blogpost diff --git a/docs/library/serializer.rst b/docs/library/serializer.rst new file mode 100644 index 00000000..63dd3308 --- /dev/null +++ b/docs/library/serializer.rst @@ -0,0 +1,5 @@ +:mod:`serializer` +================= + +.. automodule:: serializer + :members: diff --git a/examples/blogpost/models.py b/examples/blogpost/models.py index c4925a15..d77f530d 100644 --- a/examples/blogpost/models.py +++ b/examples/blogpost/models.py @@ -22,6 +22,9 @@ class BlogPost(models.Model): slug = models.SlugField(editable=False, default='') def save(self, *args, **kwargs): + """ + For the purposes of the sandbox, limit the maximum number of stored models. + """ self.slug = slugify(self.title) super(self.__class__, self).save(*args, **kwargs) for obj in self.__class__.objects.order_by('-created')[MAX_POSTS:]: diff --git a/examples/blogpost/resources.py b/examples/blogpost/resources.py new file mode 100644 index 00000000..9b91ed73 --- /dev/null +++ b/examples/blogpost/resources.py @@ -0,0 +1,27 @@ +from django.core.urlresolvers import reverse +from djangorestframework.resources import ModelResource +from blogpost.models import BlogPost, Comment + + +class BlogPostResource(ModelResource): + """ + A Blog Post has a *title* and *content*, and can be associated with zero or more comments. + """ + model = BlogPost + fields = ('created', 'title', 'slug', 'content', 'url', 'comments') + ordering = ('-created',) + + def comments(self, instance): + return reverse('comments', kwargs={'blogpost': instance.key}) + + +class CommentResource(ModelResource): + """ + A Comment is associated with a given Blog Post and has a *username* and *comment*, and optionally a *rating*. + """ + model = Comment + fields = ('username', 'comment', 'created', 'rating', 'url', 'blogpost') + ordering = ('-created',) + + def blogpost(self, instance): + return reverse('blog-post', kwargs={'key': instance.blogpost.key})
\ No newline at end of file diff --git a/examples/blogpost/tests.py b/examples/blogpost/tests.py index 9b9a682f..e55f0f90 100644 --- a/examples/blogpost/tests.py +++ b/examples/blogpost/tests.py @@ -7,79 +7,80 @@ from django.core.urlresolvers import reverse from django.utils import simplejson as json from djangorestframework.compat import RequestFactory - -from blogpost import views, models -import blogpost - - -class AcceptHeaderTests(TestCase): - """Test correct behaviour of the Accept header as specified by RFC 2616: - - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1""" - - def assert_accept_mimetype(self, mimetype, expect=None): - """Assert that a request with given mimetype in the accept header, - gives a response with the appropriate content-type.""" - if expect is None: - expect = mimetype - - resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT=mimetype) - - self.assertEquals(resp['content-type'], expect) - - - def dont_test_accept_json(self): - """Ensure server responds with Content-Type of JSON when requested.""" - self.assert_accept_mimetype('application/json') - - def dont_test_accept_xml(self): - """Ensure server responds with Content-Type of XML when requested.""" - self.assert_accept_mimetype('application/xml') - - def dont_test_accept_json_when_prefered_to_xml(self): - """Ensure server responds with Content-Type of JSON when it is the client's prefered choice.""" - self.assert_accept_mimetype('application/json;q=0.9, application/xml;q=0.1', expect='application/json') - - def dont_test_accept_xml_when_prefered_to_json(self): - """Ensure server responds with Content-Type of XML when it is the client's prefered choice.""" - self.assert_accept_mimetype('application/json;q=0.1, application/xml;q=0.9', expect='application/xml') - - def dont_test_default_json_prefered(self): - """Ensure server responds with JSON in preference to XML.""" - self.assert_accept_mimetype('application/json,application/xml', expect='application/json') - - def dont_test_accept_generic_subtype_format(self): - """Ensure server responds with an appropriate type, when the subtype is left generic.""" - self.assert_accept_mimetype('text/*', expect='text/html') - - def dont_test_accept_generic_type_format(self): - """Ensure server responds with an appropriate type, when the type and subtype are left generic.""" - self.assert_accept_mimetype('*/*', expect='application/json') - - def dont_test_invalid_accept_header_returns_406(self): - """Ensure server returns a 406 (not acceptable) response if we set the Accept header to junk.""" - resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT='invalid/invalid') - self.assertNotEquals(resp['content-type'], 'invalid/invalid') - self.assertEquals(resp.status_code, 406) - - def dont_test_prefer_specific_over_generic(self): # This test is broken right now - """More specific accept types have precedence over less specific types.""" - self.assert_accept_mimetype('application/xml, */*', expect='application/xml') - self.assert_accept_mimetype('*/*, application/xml', expect='application/xml') - - -class AllowedMethodsTests(TestCase): - """Basic tests to check that only allowed operations may be performed on a Resource""" - - def dont_test_reading_a_read_only_resource_is_allowed(self): - """GET requests on a read only resource should default to a 200 (OK) response""" - resp = self.client.get(reverse(views.RootResource)) - self.assertEquals(resp.status_code, 200) - - def dont_test_writing_to_read_only_resource_is_not_allowed(self): - """PUT requests on a read only resource should default to a 405 (method not allowed) response""" - resp = self.client.put(reverse(views.RootResource), {}) - self.assertEquals(resp.status_code, 405) +from djangorestframework.views import InstanceModelView, ListOrCreateModelView + +from blogpost import models, urls +#import blogpost + + +# class AcceptHeaderTests(TestCase): +# """Test correct behaviour of the Accept header as specified by RFC 2616: +# +# http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1""" +# +# def assert_accept_mimetype(self, mimetype, expect=None): +# """Assert that a request with given mimetype in the accept header, +# gives a response with the appropriate content-type.""" +# if expect is None: +# expect = mimetype +# +# resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT=mimetype) +# +# self.assertEquals(resp['content-type'], expect) +# +# +# def dont_test_accept_json(self): +# """Ensure server responds with Content-Type of JSON when requested.""" +# self.assert_accept_mimetype('application/json') +# +# def dont_test_accept_xml(self): +# """Ensure server responds with Content-Type of XML when requested.""" +# self.assert_accept_mimetype('application/xml') +# +# def dont_test_accept_json_when_prefered_to_xml(self): +# """Ensure server responds with Content-Type of JSON when it is the client's prefered choice.""" +# self.assert_accept_mimetype('application/json;q=0.9, application/xml;q=0.1', expect='application/json') +# +# def dont_test_accept_xml_when_prefered_to_json(self): +# """Ensure server responds with Content-Type of XML when it is the client's prefered choice.""" +# self.assert_accept_mimetype('application/json;q=0.1, application/xml;q=0.9', expect='application/xml') +# +# def dont_test_default_json_prefered(self): +# """Ensure server responds with JSON in preference to XML.""" +# self.assert_accept_mimetype('application/json,application/xml', expect='application/json') +# +# def dont_test_accept_generic_subtype_format(self): +# """Ensure server responds with an appropriate type, when the subtype is left generic.""" +# self.assert_accept_mimetype('text/*', expect='text/html') +# +# def dont_test_accept_generic_type_format(self): +# """Ensure server responds with an appropriate type, when the type and subtype are left generic.""" +# self.assert_accept_mimetype('*/*', expect='application/json') +# +# def dont_test_invalid_accept_header_returns_406(self): +# """Ensure server returns a 406 (not acceptable) response if we set the Accept header to junk.""" +# resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT='invalid/invalid') +# self.assertNotEquals(resp['content-type'], 'invalid/invalid') +# self.assertEquals(resp.status_code, 406) +# +# def dont_test_prefer_specific_over_generic(self): # This test is broken right now +# """More specific accept types have precedence over less specific types.""" +# self.assert_accept_mimetype('application/xml, */*', expect='application/xml') +# self.assert_accept_mimetype('*/*, application/xml', expect='application/xml') +# +# +# class AllowedMethodsTests(TestCase): +# """Basic tests to check that only allowed operations may be performed on a Resource""" +# +# def dont_test_reading_a_read_only_resource_is_allowed(self): +# """GET requests on a read only resource should default to a 200 (OK) response""" +# resp = self.client.get(reverse(views.RootResource)) +# self.assertEquals(resp.status_code, 200) +# +# def dont_test_writing_to_read_only_resource_is_not_allowed(self): +# """PUT requests on a read only resource should default to a 405 (method not allowed) response""" +# resp = self.client.put(reverse(views.RootResource), {}) +# self.assertEquals(resp.status_code, 405) # # def test_reading_write_only_not_allowed(self): # resp = self.client.get(reverse(views.WriteOnlyResource)) @@ -178,32 +179,33 @@ class TestRotation(TestCase): models.BlogPost.objects.all().delete() def test_get_to_root(self): - '''Simple test to demonstrate how the requestfactory needs to be used''' + '''Simple get to the *root* url of blogposts''' request = self.factory.get('/blog-post') - view = views.BlogPosts.as_view() + view = ListOrCreateModelView.as_view(resource=urls.BlogPostResource) response = view(request) self.assertEqual(response.status_code, 200) def test_blogposts_not_exceed_MAX_POSTS(self): '''Posting blog-posts should not result in more than MAX_POSTS items stored.''' - for post in range(views.MAX_POSTS + 5): + for post in range(models.MAX_POSTS + 5): form_data = {'title': 'This is post #%s' % post, 'content': 'This is the content of post #%s' % post} request = self.factory.post('/blog-post', data=form_data) - view = views.BlogPosts.as_view() + view = ListOrCreateModelView.as_view(resource=urls.BlogPostResource) view(request) - self.assertEquals(len(models.BlogPost.objects.all()),views.MAX_POSTS) + self.assertEquals(len(models.BlogPost.objects.all()),models.MAX_POSTS) def test_fifo_behaviour(self): '''It's fine that the Blogposts are capped off at MAX_POSTS. But we want to make sure we see FIFO behaviour.''' for post in range(15): form_data = {'title': '%s' % post, 'content': 'This is the content of post #%s' % post} request = self.factory.post('/blog-post', data=form_data) - view = views.BlogPosts.as_view() + view = ListOrCreateModelView.as_view(resource=urls.BlogPostResource) view(request) request = self.factory.get('/blog-post') - view = views.BlogPosts.as_view() + view = ListOrCreateModelView.as_view(resource=urls.BlogPostResource) response = view(request) response_posts = json.loads(response.content) response_titles = [d['title'] for d in response_posts] - self.assertEquals(response_titles, ['%s' % i for i in range(views.MAX_POSTS - 5, views.MAX_POSTS + 5)]) + response_titles.reverse() + self.assertEquals(response_titles, ['%s' % i for i in range(models.MAX_POSTS - 5, models.MAX_POSTS + 5)])
\ No newline at end of file diff --git a/examples/blogpost/urls.py b/examples/blogpost/urls.py index c677b8fa..e9bd2754 100644 --- a/examples/blogpost/urls.py +++ b/examples/blogpost/urls.py @@ -1,36 +1,11 @@ from django.conf.urls.defaults import patterns, url -from django.core.urlresolvers import reverse - from djangorestframework.views import ListOrCreateModelView, InstanceModelView -from djangorestframework.resources import ModelResource - -from blogpost.models import BlogPost, Comment - - -class BlogPostResource(ModelResource): - """ - A Blog Post has a *title* and *content*, and can be associated with zero or more comments. - """ - model = BlogPost - fields = ('created', 'title', 'slug', 'content', 'url', 'comments') - ordering = ('-created',) - - def comments(self, instance): - return reverse('comments', kwargs={'blogpost': instance.key}) - - -class CommentResource(ModelResource): - """ - A Comment is associated with a given Blog Post and has a *username* and *comment*, and optionally a *rating*. - """ - model = Comment - fields = ('username', 'comment', 'created', 'rating', 'url', 'blogpost') - ordering = ('-created',) +from blogpost.resources import BlogPostResource, CommentResource urlpatterns = patterns('', url(r'^$', ListOrCreateModelView.as_view(resource=BlogPostResource), name='blog-posts-root'), - url(r'^(?P<key>[^/]+)/$', InstanceModelView.as_view(resource=BlogPostResource)), + url(r'^(?P<key>[^/]+)/$', InstanceModelView.as_view(resource=BlogPostResource), name='blog-post'), url(r'^(?P<blogpost>[^/]+)/comments/$', ListOrCreateModelView.as_view(resource=CommentResource), name='comments'), url(r'^(?P<blogpost>[^/]+)/comments/(?P<id>[^/]+)/$', InstanceModelView.as_view(resource=CommentResource)), ) diff --git a/examples/modelresourceexample/resources.py b/examples/modelresourceexample/resources.py new file mode 100644 index 00000000..634ea6b3 --- /dev/null +++ b/examples/modelresourceexample/resources.py @@ -0,0 +1,7 @@ +from djangorestframework.resources import ModelResource +from modelresourceexample.models import MyModel + +class MyModelResource(ModelResource): + model = MyModel + fields = ('foo', 'bar', 'baz', 'url') + ordering = ('created',) diff --git a/examples/modelresourceexample/urls.py b/examples/modelresourceexample/urls.py index bb71ddd3..b6a16542 100644 --- a/examples/modelresourceexample/urls.py +++ b/examples/modelresourceexample/urls.py @@ -1,14 +1,8 @@ from django.conf.urls.defaults import patterns, url from djangorestframework.views import ListOrCreateModelView, InstanceModelView -from djangorestframework.resources import ModelResource -from modelresourceexample.models import MyModel - -class MyModelResource(ModelResource): - model = MyModel - fields = ('foo', 'bar', 'baz', 'url') - ordering = ('created',) +from modelresourceexample.resources import MyModelResource urlpatterns = patterns('', url(r'^$', ListOrCreateModelView.as_view(resource=MyModelResource), name='model-resource-root'), - url(r'^([0-9]+)/$', InstanceModelView.as_view(resource=MyModelResource)), + url(r'^(?P<pk>[0-9]+)/$', InstanceModelView.as_view(resource=MyModelResource)), ) diff --git a/examples/modelresourceexample/views.py b/examples/permissionsexample/__init__.py index e69de29b..e69de29b 100644 --- a/examples/modelresourceexample/views.py +++ b/examples/permissionsexample/__init__.py diff --git a/examples/permissionsexample/urls.py b/examples/permissionsexample/urls.py new file mode 100644 index 00000000..d17f5159 --- /dev/null +++ b/examples/permissionsexample/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls.defaults import patterns, url +from permissionsexample.views import ThrottlingExampleView + +urlpatterns = patterns('', + url(r'^$', ThrottlingExampleView.as_view(), name='throttled-resource'), +) diff --git a/examples/permissionsexample/views.py b/examples/permissionsexample/views.py new file mode 100644 index 00000000..20e7cba7 --- /dev/null +++ b/examples/permissionsexample/views.py @@ -0,0 +1,20 @@ +from djangorestframework.views import View +from djangorestframework.permissions import PerUserThrottling + + +class ThrottlingExampleView(View): + """ + A basic read-only View that has a **per-user throttle** of 10 requests per minute. + + If a user exceeds the 10 requests limit within a period of one minute, the + throttle will be applied until 60 seconds have passed since the first request. + """ + + permissions = ( PerUserThrottling, ) + throttle = '10/min' + + def get(self, request): + """ + Handle GET requests. + """ + return "Successful response to GET request because throttle is not yet active."
\ No newline at end of file diff --git a/examples/pygments_api/views.py b/examples/pygments_api/views.py index 76647107..e50029f6 100644 --- a/examples/pygments_api/views.py +++ b/examples/pygments_api/views.py @@ -46,19 +46,12 @@ class HTMLRenderer(BaseRenderer): media_type = 'text/html' - -class PygmentsFormResource(FormResource): - """ - """ - form = PygmentsForm - - class PygmentsRoot(View): """ - This example demonstrates a simple RESTful Web API aound the awesome pygments library. + This example demonstrates a simple RESTful Web API around the awesome pygments library. This top level resource is used to create highlighted code snippets, and to list all the existing code snippets. """ - resource = PygmentsFormResource + form = PygmentsForm def get(self, request): """ diff --git a/examples/resourceexample/urls.py b/examples/resourceexample/urls.py index cb6435bb..6e141f3c 100644 --- a/examples/resourceexample/urls.py +++ b/examples/resourceexample/urls.py @@ -1,7 +1,7 @@ from django.conf.urls.defaults import patterns, url -from resourceexample.views import ExampleResource, AnotherExampleResource +from resourceexample.views import ExampleView, AnotherExampleView urlpatterns = patterns('', - url(r'^$', ExampleResource.as_view(), name='example-resource'), - url(r'^(?P<num>[0-9]+)/$', AnotherExampleResource.as_view(), name='another-example-resource'), + url(r'^$', ExampleView.as_view(), name='example-resource'), + url(r'^(?P<num>[0-9]+)/$', AnotherExampleView.as_view(), name='another-example'), ) diff --git a/examples/resourceexample/views.py b/examples/resourceexample/views.py index 29651fbf..990c7834 100644 --- a/examples/resourceexample/views.py +++ b/examples/resourceexample/views.py @@ -1,42 +1,45 @@ from django.core.urlresolvers import reverse from djangorestframework.views import View -from djangorestframework.resources import FormResource from djangorestframework.response import Response from djangorestframework import status from resourceexample.forms import MyForm -class MyFormValidation(FormResource): - """ - A resource which applies form validation on the input. - """ - form = MyForm - -class ExampleResource(View): +class ExampleView(View): """ - A basic read-only resource that points to 3 other resources. + A basic read-only view that points to 3 other views. """ def get(self, request): - return {"Some other resources": [reverse('another-example-resource', kwargs={'num':num}) for num in range(3)]} + """ + Handle GET requests, returning a list of URLs pointing to 3 other views. + """ + return {"Some other resources": [reverse('another-example', kwargs={'num':num}) for num in range(3)]} -class AnotherExampleResource(View): +class AnotherExampleView(View): """ - A basic GET-able/POST-able resource. + A basic view, that can be handle GET and POST requests. + Applies some simple form validation on POST requests. """ - resource = MyFormValidation + form = MyForm def get(self, request, num): - """Handle GET requests""" + """ + Handle GET requests. + Returns a simple string indicating which view the GET request was for. + """ if int(num) > 2: return Response(status.HTTP_404_NOT_FOUND) return "GET request to AnotherExampleResource %s" % num def post(self, request, num): - """Handle POST requests""" + """ + Handle POST requests, with form validation. + Returns a simple string indicating what content was supplied. + """ if int(num) > 2: return Response(status.HTTP_404_NOT_FOUND) return "POST request to AnotherExampleResource %s, with content: %s" % (num, repr(self.CONTENT)) diff --git a/examples/sandbox/views.py b/examples/sandbox/views.py index 1c55c28f..1e326f43 100644 --- a/examples/sandbox/views.py +++ b/examples/sandbox/views.py @@ -31,4 +31,6 @@ class Sandbox(View): {'name': 'Simple Mixin-only example', 'url': reverse('mixin-view')}, {'name': 'Object store API', 'url': reverse('object-store-root')}, {'name': 'Code highlighting API', 'url': reverse('pygments-root')}, - {'name': 'Blog posts API', 'url': reverse('blog-posts-root')}] + {'name': 'Blog posts API', 'url': reverse('blog-posts-root')}, + {'name': 'Permissions example', 'url': reverse('throttled-resource')} + ] diff --git a/examples/urls.py b/examples/urls.py index cf4d4042..08d97a14 100644 --- a/examples/urls.py +++ b/examples/urls.py @@ -10,6 +10,7 @@ urlpatterns = patterns('', (r'^object-store/', include('objectstore.urls')), (r'^pygments/', include('pygments_api.urls')), (r'^blog-post/', include('blogpost.urls')), + (r'^permissions-example/', include('permissionsexample.urls')), (r'^', include('djangorestframework.urls')), ) @@ -3,13 +3,19 @@ from setuptools import setup +import os, re + +path = os.path.join(os.path.dirname(__file__), 'djangorestframework', '__init__.py') +init_py = open(path).read() +VERSION = re.match("__version__ = '([^']+)'", init_py).group(1) + setup( - name = "djangorestframework", - version = "0.1", - url = 'https://bitbucket.org/tomchristie/django-rest-framework/wiki/Home', - download_url = 'https://bitbucket.org/tomchristie/django-rest-framework/downloads', + name = 'djangorestframework', + version = VERSION, + url = 'http://django-rest-framework.org', + download_url = 'http://pypi.python.org/pypi/djangorestframework/', license = 'BSD', - description = "A lightweight REST framework for Django.", + description = 'A lightweight REST framework for Django.', author = 'Tom Christie', author_email = 'tom@tomchristie.com', packages = ['djangorestframework', @@ -31,5 +37,3 @@ setup( 'Topic :: Internet :: WWW/HTTP', ] ) - - |
