Commit 8f58ee48 by Tom Christie

Getting the API into shape

parent d373b3a0
"""The :mod:`authentication` modules provides for pluggable authentication behaviour.
"""
The ``authentication`` module provides a set of pluggable authentication classes.
Authentication behaviour is provided by adding the mixin class :class:`AuthenticatorMixin` to a :class:`.BaseView` or Django :class:`View` class.
Authentication behavior is provided by adding the ``AuthMixin`` class to a ``View`` .
The set of authentication which are use is then specified by setting the :attr:`authentication` attribute on the class, and listing a set of authentication classes.
The set of authentication methods which are used is then specified by setting
``authentication`` attribute on the ``View`` class, and listing a set of authentication classes.
"""
from django.contrib.auth import authenticate
from django.middleware.csrf import CsrfViewMiddleware
from djangorestframework.utils import as_tuple
import base64
__all__ = (
'BaseAuthenticaton',
'BasicAuthenticaton',
'UserLoggedInAuthenticaton'
)
class BaseAuthenticator(object):
"""All authentication should extend BaseAuthenticator."""
class BaseAuthenticaton(object):
"""
All authentication classes should extend BaseAuthentication.
"""
def __init__(self, view):
"""Initialise the authentication with the mixin instance as state,
in case the authentication needs to access any metadata on the mixin object."""
"""
Authentication classes are always passed the current view on creation.
"""
self.view = view
def authenticate(self, request):
"""Authenticate the request and return the authentication context or None.
An authentication context might be something as simple as a User object, or it might
be some more complicated token, for example authentication tokens which are signed
against a particular set of permissions for a given user, over a given timeframe.
"""
Authenticate the request and return a ``User`` instance or None. (*)
The default permission checking on View will use the allowed_methods attribute
for permissions if the authentication context is not None, and use anon_allowed_methods otherwise.
The authentication context is available to the method calls eg View.get(request)
by accessing self.auth in order to allow them to apply any more fine grained permission
checking at the point the response is being generated.
This function must be overridden to be implemented.
This function must be overridden to be implemented."""
(*) The authentication context _will_ typically be a ``User`` object,
but it need not be. It can be any user-like object so long as the
permissions classes on the view can handle the object and use
it to determine if the request has the required permissions or not.
This can be an important distinction if you're implementing some token
based authentication mechanism, where the authentication context
may be more involved than simply mapping to a ``User``.
"""
return None
class BasicAuthenticator(BaseAuthenticator):
"""Use HTTP Basic authentication"""
class BasicAuthenticaton(BaseAuthenticaton):
"""
Use HTTP Basic authentication.
"""
def authenticate(self, request):
from django.utils.encoding import smart_unicode, DjangoUnicodeDecodeError
......@@ -60,9 +75,13 @@ class BasicAuthenticator(BaseAuthenticator):
return None
class UserLoggedInAuthenticator(BaseAuthenticator):
"""Use Django's built-in request session for authentication."""
class UserLoggedInAuthenticaton(BaseAuthenticaton):
"""
Use Django's session framework for authentication.
"""
def authenticate(self, request):
# TODO: Switch this back to request.POST, and let MultiPartParser deal with the consequences.
if getattr(request, 'user', None) and request.user.is_active:
# If this is a POST request we enforce CSRF validation.
if request.method.upper() == 'POST':
......@@ -77,8 +96,4 @@ class UserLoggedInAuthenticator(BaseAuthenticator):
return None
#class DigestAuthentication(BaseAuthentication):
# pass
#
#class OAuthAuthentication(BaseAuthentication):
# pass
# TODO: TokenAuthentication, DigestAuthentication, OAuthAuthentication
"""Django supports parsing the content of an HTTP request, but only for form POST requests.
That behaviour is sufficient for dealing with standard HTML forms, but it doesn't map well
"""
Django supports parsing the content of an HTTP request, but only for form POST requests.
That behavior is sufficient for dealing with standard HTML forms, but it doesn't map well
to general HTTP requests.
We need a method to be able to:
......@@ -8,54 +9,72 @@ We need a method to be able to:
2) Determine the parsed content on a request for media types other than application/x-www-form-urlencoded
and multipart/form-data. (eg also handle multipart/json)
"""
from django.http.multipartparser import MultiPartParser as DjangoMPParser
from django.utils import simplejson as json
from djangorestframework.response import ErrorResponse
from django.http.multipartparser import MultiPartParser as DjangoMultiPartParser
from django.utils import simplejson as json
from djangorestframework import status
from djangorestframework.utils import as_tuple
from djangorestframework.utils.mediatypes import MediaType
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__ = (
'BaseParser',
'JSONParser',
'PlainTextParser',
'FormParser',
'MultiPartParser'
)
class BaseParser(object):
"""All parsers should extend BaseParser, specifying a media_type attribute,
and overriding the parse() method."""
"""
All parsers should extend BaseParser, specifying a media_type attribute,
and overriding the parse() method.
"""
media_type = None
def __init__(self, view):
"""
Initialise the parser with the View instance as state,
in case the parser needs to access any metadata on the View object.
Initialize the parser with the ``View`` instance as state,
in case the parser needs to access any metadata on the ``View`` object.
"""
self.view = view
@classmethod
def handles(self, media_type):
def can_handle_request(self, media_type):
"""
Returns `True` if this parser is able to deal with the given MediaType.
Returns `True` if this parser is able to deal with the given media type.
The default implementation for this function is to check the ``media_type``
argument against the ``media_type`` attribute set on the class to see if
they match.
This may be overridden to provide for other behavior, but typically you'll
instead want to just set the ``media_type`` attribute on the class.
"""
return media_type.match(self.media_type)
return media_type_matches(media_type, self.media_type)
def parse(self, stream):
"""Given a stream to read from, return the deserialized output.
The return value may be of any type, but for many parsers it might typically be a dict-like object."""
"""
Given a stream to read from, return the deserialized output.
The return value may be of any type, but for many parsers it might typically be a dict-like object.
"""
raise NotImplementedError("BaseParser.parse() Must be overridden to be implemented.")
class JSONParser(BaseParser):
media_type = MediaType('application/json')
media_type = 'application/json'
def parse(self, stream):
try:
return json.load(stream)
except ValueError, exc:
raise ErrorResponse(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)})
raise ErrorResponse(status.HTTP_400_BAD_REQUEST,
{'detail': 'JSON parse error - %s' % unicode(exc)})
class DataFlatener(object):
"""Utility object for flatening dictionaries of lists. Useful for "urlencoded" decoded data."""
"""Utility object for flattening dictionaries of lists. Useful for "urlencoded" decoded data."""
def flatten_data(self, data):
"""Given a data dictionary {<key>: <value_list>}, returns a flattened dictionary
......@@ -83,9 +102,9 @@ class PlainTextParser(BaseParser):
"""
Plain text parser.
Simply returns the content of the stream
Simply returns the content of the stream.
"""
media_type = MediaType('text/plain')
media_type = 'text/plain'
def parse(self, stream):
return stream.read()
......@@ -98,7 +117,7 @@ class FormParser(BaseParser, DataFlatener):
In order to handle select multiple (and having possibly more than a single value for each parameter),
you can customize the output by subclassing the method 'is_a_list'."""
media_type = MediaType('application/x-www-form-urlencoded')
media_type = 'application/x-www-form-urlencoded'
"""The value of the parameter when the select multiple is empty.
Browsers are usually stripping the select multiple that have no option selected from the parameters sent.
......@@ -138,14 +157,14 @@ class MultipartData(dict):
dict.__init__(self, data)
self.FILES = files
class MultipartParser(BaseParser, DataFlatener):
media_type = MediaType('multipart/form-data')
class MultiPartParser(BaseParser, DataFlatener):
media_type = 'multipart/form-data'
RESERVED_FORM_PARAMS = ('csrfmiddlewaretoken',)
def parse(self, stream):
upload_handlers = self.view.request._get_upload_handlers()
django_mpp = DjangoMPParser(self.view.request.META, stream, upload_handlers)
data, files = django_mpp.parse()
django_parser = DjangoMultiPartParser(self.view.request.META, stream, upload_handlers)
data, files = django_parser.parse()
# Flatening data, files and combining them
data = self.flatten_data(dict(data.iterlists()))
......
from django.core.cache import cache
from djangorestframework import status
from djangorestframework.response import ErrorResponse
import time
__all__ = (
'BasePermission',
'FullAnonAccess',
'IsAuthenticated',
'IsAdminUser',
'IsUserOrIsAnonReadOnly',
'PerUserThrottling'
)
_403_FORBIDDEN_RESPONSE = ErrorResponse(
status.HTTP_403_FORBIDDEN,
{'detail': 'You do not have permission to access this resource. ' +
'You may need to login or otherwise authenticate the request.'})
_503_THROTTLED_RESPONSE = ErrorResponse(
status.HTTP_503_SERVICE_UNAVAILABLE,
{'detail': 'request was throttled'})
class BasePermission(object):
"""A base class from which all permission classes should inherit."""
"""
A base class from which all permission classes should inherit.
"""
def __init__(self, view):
"""
Permission classes are always passed the current view on creation.
"""
self.view = view
def has_permission(self, auth):
return True
def check_permission(self, auth):
"""
Should simply return, or raise an ErrorResponse.
"""
pass
class FullAnonAccess(BasePermission):
""""""
def has_permission(self, auth):
return True
"""
Allows full access.
"""
def check_permission(self, user):
pass
class IsAuthenticated(BasePermission):
""""""
def has_permission(self, auth):
return auth is not None and auth.is_authenticated()
#class IsUser(BasePermission):
# """The request has authenticated as a user."""
# def has_permission(self, auth):
# pass
#
#class IsAdminUser():
# """The request has authenticated as an admin user."""
# def has_permission(self, auth):
# pass
#
#class IsUserOrIsAnonReadOnly(BasePermission):
# """The request has authenticated as a user, or is a read-only request."""
# def has_permission(self, auth):
# pass
#
#class OAuthTokenInScope(BasePermission):
# def has_permission(self, auth):
# pass
#
#class UserHasModelPermissions(BasePermission):
# def has_permission(self, auth):
# pass
"""
Allows access only to authenticated users.
"""
class Throttling(BasePermission):
"""Rate throttling of requests on a per-user basis.
def check_permission(self, user):
if not user.is_authenticated():
raise _403_FORBIDDEN_RESPONSE
The rate is set by a 'throttle' attribute on the view class.
class IsAdminUser():
"""
Allows access only to admin users.
"""
def check_permission(self, user):
if not user.is_admin():
raise _403_FORBIDDEN_RESPONSE
class IsUserOrIsAnonReadOnly(BasePermission):
"""
The request is authenticated as a user, or is a read-only request.
"""
def check_permission(self, user):
if (not user.is_authenticated() and
self.view.method != 'GET' and
self.view.method != 'HEAD'):
raise _403_FORBIDDEN_RESPONSE
class PerUserThrottling(BasePermission):
"""
Rate throttling of requests on a per-user basis.
The rate is set by a 'throttle' attribute on the ``View`` class.
The attribute is a two tuple of the form (number of requests, duration in seconds).
The user's id will be used as a unique identifier if the user is authenticated.
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.
Previous request information used for throttling is stored in the cache.
"""
def has_permission(self, auth):
def check_permission(self, user):
(num_requests, duration) = getattr(self.view, 'throttle', (0, 0))
if auth.is_authenticated():
if user.is_authenticated():
ident = str(auth)
else:
ident = self.view.request.META.get('REMOTE_ADDR', None)
......@@ -74,7 +111,7 @@ class Throttling(BasePermission):
history.pop()
if len(history) >= num_requests:
raise ErrorResponse(status.HTTP_503_SERVICE_UNAVAILABLE, {'detail': 'request was throttled'})
raise _503_THROTTLED_RESPONSE
history.insert(0, now)
cache.set(key, history, duration)
cache.set(key, history, duration)
......@@ -29,8 +29,8 @@ class BaseRenderer(object):
override the render() function."""
media_type = None
def __init__(self, resource):
self.resource = resource
def __init__(self, view):
self.view = view
def render(self, output=None, verbose=False):
"""By default render simply returns the ouput as-is.
......@@ -42,8 +42,11 @@ class BaseRenderer(object):
class TemplateRenderer(BaseRenderer):
"""Provided for convienience.
Render the output by simply rendering it with the given template."""
"""A Base class provided for convenience.
Render the output simply by using the given template.
To create a template renderer, subclass this, and set
the ``media_type`` and ``template`` attributes"""
media_type = None
template = None
......@@ -139,7 +142,7 @@ class DocumentingTemplateRenderer(BaseRenderer):
widget=forms.Textarea)
# If either of these reserved parameters are turned off then content tunneling is not possible
if self.resource.CONTENTTYPE_PARAM is None or self.resource.CONTENT_PARAM is None:
if self.view.CONTENTTYPE_PARAM is None or self.view.CONTENT_PARAM is None:
return None
# Okey doke, let's do it
......@@ -147,18 +150,18 @@ class DocumentingTemplateRenderer(BaseRenderer):
def render(self, output=None):
content = self._get_content(self.resource, self.resource.request, output)
form_instance = self._get_form_instance(self.resource)
content = self._get_content(self.view, self.view.request, output)
form_instance = self._get_form_instance(self.view)
if url_resolves(settings.LOGIN_URL) and url_resolves(settings.LOGOUT_URL):
login_url = "%s?next=%s" % (settings.LOGIN_URL, quote_plus(self.resource.request.path))
logout_url = "%s?next=%s" % (settings.LOGOUT_URL, quote_plus(self.resource.request.path))
login_url = "%s?next=%s" % (settings.LOGIN_URL, quote_plus(self.view.request.path))
logout_url = "%s?next=%s" % (settings.LOGOUT_URL, quote_plus(self.view.request.path))
else:
login_url = None
logout_url = None
name = get_name(self.resource)
description = get_description(self.resource)
name = get_name(self.view)
description = get_description(self.view)
markeddown = None
if apply_markdown:
......@@ -167,14 +170,14 @@ class DocumentingTemplateRenderer(BaseRenderer):
except AttributeError: # TODO: possibly split the get_description / get_name into a mixin class
markeddown = None
breadcrumb_list = get_breadcrumbs(self.resource.request.path)
breadcrumb_list = get_breadcrumbs(self.view.request.path)
template = loader.get_template(self.template)
context = RequestContext(self.resource.request, {
context = RequestContext(self.view.request, {
'content': content,
'resource': self.resource,
'request': self.resource.request,
'response': self.resource.response,
'resource': self.view,
'request': self.view.request,
'response': self.view.response,
'description': description,
'name': name,
'markeddown': markeddown,
......@@ -233,11 +236,12 @@ class DocumentingPlainTextRenderer(DocumentingTemplateRenderer):
Useful for browsing an API with command line tools."""
media_type = 'text/plain'
template = 'renderer.txt'
DEFAULT_RENDERERS = ( JSONRenderer,
DocumentingHTMLRenderer,
DocumentingXHTMLRenderer,
DocumentingPlainTextRenderer,
XMLRenderer )
DocumentingHTMLRenderer,
DocumentingXHTMLRenderer,
DocumentingPlainTextRenderer,
XMLRenderer )
......@@ -4,7 +4,7 @@ Tests for content parsing, and form-overloaded content parsing.
from django.test import TestCase
from djangorestframework.compat import RequestFactory
from djangorestframework.mixins import RequestMixin
from djangorestframework.parsers import FormParser, MultipartParser, PlainTextParser
from djangorestframework.parsers import FormParser, MultiPartParser, PlainTextParser
class TestContentParsing(TestCase):
......@@ -19,7 +19,7 @@ class TestContentParsing(TestCase):
def ensure_determines_form_content_POST(self, view):
"""Ensure view.RAW_CONTENT returns content for POST request with form content."""
form_data = {'qwerty': 'uiop'}
view.parsers = (FormParser, MultipartParser)
view.parsers = (FormParser, MultiPartParser)
view.request = self.req.post('/', data=form_data)
self.assertEqual(view.RAW_CONTENT, form_data)
......@@ -34,7 +34,7 @@ class TestContentParsing(TestCase):
def ensure_determines_form_content_PUT(self, view):
"""Ensure view.RAW_CONTENT returns content for PUT request with form content."""
form_data = {'qwerty': 'uiop'}
view.parsers = (FormParser, MultipartParser)
view.parsers = (FormParser, MultiPartParser)
view.request = self.req.put('/', data=form_data)
self.assertEqual(view.RAW_CONTENT, form_data)
......
......@@ -39,7 +39,7 @@ This new parser only flattens the lists of parameters that contain a single valu
>>> MyFormParser(some_view).parse(StringIO(inpt)) == {'key1': 'bla1', 'key2': ['blo1', 'blo2']}
True
.. note:: The same functionality is available for :class:`parsers.MultipartParser`.
.. note:: The same functionality is available for :class:`parsers.MultiPartParser`.
Submitting an empty list
--------------------------
......@@ -80,9 +80,8 @@ import httplib, mimetypes
from tempfile import TemporaryFile
from django.test import TestCase
from djangorestframework.compat import RequestFactory
from djangorestframework.parsers import MultipartParser
from djangorestframework.parsers import MultiPartParser
from djangorestframework.views import BaseView
from djangorestframework.utils.mediatypes import MediaType
from StringIO import StringIO
def encode_multipart_formdata(fields, files):
......@@ -113,18 +112,18 @@ def encode_multipart_formdata(fields, files):
def get_content_type(filename):
return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
class TestMultipartParser(TestCase):
class TestMultiPartParser(TestCase):
def setUp(self):
self.req = RequestFactory()
self.content_type, self.body = encode_multipart_formdata([('key1', 'val1'), ('key1', 'val2')],
[('file1', 'pic.jpg', 'blablabla'), ('file1', 't.txt', 'blobloblo')])
def test_multipartparser(self):
"""Ensure that MultipartParser can parse multipart/form-data that contains a mix of several files and parameters."""
"""Ensure that MultiPartParser can parse multipart/form-data that contains a mix of several files and parameters."""
post_req = RequestFactory().post('/', self.body, content_type=self.content_type)
view = BaseView()
view.request = post_req
parsed = MultipartParser(view).parse(StringIO(self.body))
parsed = MultiPartParser(view).parse(StringIO(self.body))
self.assertEqual(parsed['key1'], 'val1')
self.assertEqual(parsed.FILES['file1'].read(), 'blablabla')
......@@ -4,11 +4,11 @@ from django.utils import simplejson as json
from djangorestframework.compat import RequestFactory
from djangorestframework.views import BaseView
from djangorestframework.permissions import Throttling
from djangorestframework.permissions import PerUserThrottling
class MockView(BaseView):
permissions = ( Throttling, )
permissions = ( PerUserThrottling, )
throttle = (3, 1) # 3 requests per second
def get(self, request):
......
......@@ -7,11 +7,39 @@ See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7
from django.http.multipartparser import parse_header
class MediaType(object):
def media_type_matches(lhs, rhs):
"""
Returns ``True`` if the media type in the first argument <= the
media type in the second argument. The media types are strings
as described by the HTTP spec.
Valid media type strings include:
'application/json indent=4'
'application/json'
'text/*'
'*/*'
"""
lhs = _MediaType(lhs)
rhs = _MediaType(rhs)
return lhs.match(rhs)
def is_form_media_type(media_type):
"""
Return True if the media type is a valid form media type as defined by the HTML4 spec.
(NB. HTML5 also adds text/plain to the list of valid form media types, but we don't support this here)
"""
media_type = _MediaType(media_type)
return media_type.full_type == 'application/x-www-form-urlencoded' or \
media_type.full_type == 'multipart/form-data'
class _MediaType(object):
def __init__(self, media_type_str):
self.orig = media_type_str
self.media_type, self.params = parse_header(media_type_str)
self.main_type, sep, self.sub_type = self.media_type.partition('/')
self.full_type, self.params = parse_header(media_type_str)
self.main_type, sep, self.sub_type = self.full_type.partition('/')
def match(self, other):
"""Return true if this MediaType satisfies the constraint of the given MediaType."""
......@@ -55,14 +83,6 @@ class MediaType(object):
# NB. quality values should only have up to 3 decimal points
# http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9
return self.quality * 10000 + self.precedence
def is_form(self):
"""
Return True if the MediaType is a valid form media type as defined by the HTML4 spec.
(NB. HTML5 also adds text/plain to the list of valid form media types, but we don't support this here)
"""
return self.media_type == 'application/x-www-form-urlencoded' or \
self.media_type == 'multipart/form-data'
def as_tuple(self):
return (self.main_type, self.sub_type, self.params)
......
......@@ -7,11 +7,11 @@ from djangorestframework.mixins import *
from djangorestframework import resource, renderers, parsers, authentication, permissions, validators, status
__all__ = ['BaseView',
__all__ = ('BaseView',
'ModelView',
'InstanceModelView',
'ListOrModelView',
'ListOrCreateModelView']
'ListOrCreateModelView')
......@@ -32,14 +32,14 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View):
# List of parsers the resource can parse the request with.
parsers = ( parsers.JSONParser,
parsers.FormParser,
parsers.MultipartParser )
parsers.MultiPartParser )
# List of validators to validate, cleanup and normalize the request content
validators = ( validators.FormValidator, )
# List of all authenticating methods to attempt.
authentication = ( authentication.UserLoggedInAuthenticator,
authentication.BasicAuthenticator )
authentication = ( authentication.UserLoggedInAuthenticaton,
authentication.BasicAuthenticaton )
# List of all permissions that must be checked.
permissions = ( permissions.FullAnonAccess, )
......@@ -92,7 +92,7 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View):
self.perform_form_overloading()
# Authenticate and check request is has the relevant permissions
self.check_permissions()
self._check_permissions()
# Get the appropriate handler method
if self.method.lower() in self.http_method_names:
......@@ -112,9 +112,12 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View):
# Pre-serialize filtering (eg filter complex objects into natively serializable types)
response.cleaned_content = self.resource.object_to_serializable(response.raw_content)
except ErrorResponse, exc:
response = exc.response
except:
import traceback
traceback.print_exc()
# Always add these headers.
#
......@@ -124,6 +127,7 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View):
response.headers['Vary'] = 'Authenticate, Accept'
return self.render(response)
class ModelView(BaseView):
......@@ -134,11 +138,11 @@ class InstanceModelView(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, Mode
"""A view which provides default operations for read/update/delete against a model instance."""
pass
class ListModelResource(ListModelMixin, ModelView):
class ListModelView(ListModelMixin, ModelView):
"""A view which provides default operations for list, against a model in the database."""
pass
class ListOrCreateModelResource(ListModelMixin, CreateModelMixin, ModelView):
class ListOrCreateModelView(ListModelMixin, CreateModelMixin, ModelView):
"""A view which provides default operations for list and create, against a model in the database."""
pass
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment