From b358fbdbe9cbd4ce644c4b2c7b9b4cec0811e14e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 29 Apr 2011 14:32:56 +0100 Subject: More refactoring - move various less core stuff into utils etc --- djangorestframework/authentication.py | 84 ++++++++++ djangorestframework/authenticators.py | 84 ---------- djangorestframework/breadcrumbs.py | 31 ---- djangorestframework/compat.py | 66 +++++++- djangorestframework/description.py | 37 ----- djangorestframework/emitters.py | 243 ---------------------------- djangorestframework/markdownwrapper.py | 51 ------ djangorestframework/mediatypes.py | 78 --------- djangorestframework/mixins.py | 10 +- djangorestframework/parsers.py | 2 +- djangorestframework/renderers.py | 243 ++++++++++++++++++++++++++++ djangorestframework/resource.py | 6 +- djangorestframework/templates/emitter.html | 127 --------------- djangorestframework/templates/emitter.txt | 8 - djangorestframework/templates/renderer.html | 127 +++++++++++++++ djangorestframework/templates/renderer.txt | 8 + djangorestframework/tests/breadcrumbs.py | 2 +- djangorestframework/tests/description.py | 4 +- djangorestframework/tests/emitters.py | 76 --------- djangorestframework/tests/parsers.py | 2 +- djangorestframework/tests/renderers.py | 76 +++++++++ djangorestframework/utils.py | 159 ------------------ djangorestframework/utils/__init__.py | 158 ++++++++++++++++++ djangorestframework/utils/breadcrumbs.py | 30 ++++ djangorestframework/utils/description.py | 37 +++++ djangorestframework/utils/mediatypes.py | 78 +++++++++ 26 files changed, 913 insertions(+), 914 deletions(-) create mode 100644 djangorestframework/authentication.py delete mode 100644 djangorestframework/authenticators.py delete mode 100644 djangorestframework/breadcrumbs.py delete mode 100644 djangorestframework/description.py delete mode 100644 djangorestframework/emitters.py delete mode 100644 djangorestframework/markdownwrapper.py delete mode 100644 djangorestframework/mediatypes.py create mode 100644 djangorestframework/renderers.py delete mode 100644 djangorestframework/templates/emitter.html delete mode 100644 djangorestframework/templates/emitter.txt create mode 100644 djangorestframework/templates/renderer.html create mode 100644 djangorestframework/templates/renderer.txt delete mode 100644 djangorestframework/tests/emitters.py create mode 100644 djangorestframework/tests/renderers.py delete mode 100644 djangorestframework/utils.py create mode 100644 djangorestframework/utils/__init__.py create mode 100644 djangorestframework/utils/breadcrumbs.py create mode 100644 djangorestframework/utils/description.py create mode 100644 djangorestframework/utils/mediatypes.py (limited to 'djangorestframework') diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py new file mode 100644 index 00000000..894b34fc --- /dev/null +++ b/djangorestframework/authentication.py @@ -0,0 +1,84 @@ +"""The :mod:`authentication` modules provides for pluggable authentication behaviour. + +Authentication behaviour is provided by adding the mixin class :class:`AuthenticatorMixin` to a :class:`.Resource` or Django :class:`View` class. + +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. +""" +from django.contrib.auth import authenticate +from django.middleware.csrf import CsrfViewMiddleware +from djangorestframework.utils import as_tuple +import base64 + + +class BaseAuthenticator(object): + """All authentication should extend BaseAuthenticator.""" + + 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.""" + 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. + + The default permission checking on Resource 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 Resource.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.""" + return None + + +class BasicAuthenticator(BaseAuthenticator): + """Use HTTP Basic authentication""" + def authenticate(self, request): + from django.utils.encoding import smart_unicode, DjangoUnicodeDecodeError + + if 'HTTP_AUTHORIZATION' in request.META: + auth = request.META['HTTP_AUTHORIZATION'].split() + if len(auth) == 2 and auth[0].lower() == "basic": + try: + auth_parts = base64.b64decode(auth[1]).partition(':') + except TypeError: + return None + + try: + uname, passwd = smart_unicode(auth_parts[0]), smart_unicode(auth_parts[2]) + except DjangoUnicodeDecodeError: + return None + + user = authenticate(username=uname, password=passwd) + if user is not None and user.is_active: + return user + return None + + +class UserLoggedInAuthenticator(BaseAuthenticator): + """Use Django's built-in request session for authentication.""" + def authenticate(self, request): + 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': + # Temporarily replace request.POST with .RAW_CONTENT, + # so that we use our more generic request parsing + request._post = self.view.RAW_CONTENT + resp = CsrfViewMiddleware().process_view(request, None, (), {}) + del(request._post) + if resp is not None: # csrf failed + return None + return request.user + return None + + +#class DigestAuthentication(BaseAuthentication): +# pass +# +#class OAuthAuthentication(BaseAuthentication): +# pass diff --git a/djangorestframework/authenticators.py b/djangorestframework/authenticators.py deleted file mode 100644 index 74d9931a..00000000 --- a/djangorestframework/authenticators.py +++ /dev/null @@ -1,84 +0,0 @@ -"""The :mod:`authenticators` modules provides for pluggable authentication behaviour. - -Authentication behaviour is provided by adding the mixin class :class:`AuthenticatorMixin` to a :class:`.Resource` or Django :class:`View` class. - -The set of authenticators which are use is then specified by setting the :attr:`authenticators` attribute on the class, and listing a set of authenticator classes. -""" -from django.contrib.auth import authenticate -from django.middleware.csrf import CsrfViewMiddleware -from djangorestframework.utils import as_tuple -import base64 - - -class BaseAuthenticator(object): - """All authenticators should extend BaseAuthenticator.""" - - def __init__(self, view): - """Initialise the authenticator with the mixin instance as state, - in case the authenticator needs to access any metadata on the mixin object.""" - 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. - - The default permission checking on Resource 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 Resource.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.""" - return None - - -class BasicAuthenticator(BaseAuthenticator): - """Use HTTP Basic authentication""" - def authenticate(self, request): - from django.utils.encoding import smart_unicode, DjangoUnicodeDecodeError - - if 'HTTP_AUTHORIZATION' in request.META: - auth = request.META['HTTP_AUTHORIZATION'].split() - if len(auth) == 2 and auth[0].lower() == "basic": - try: - auth_parts = base64.b64decode(auth[1]).partition(':') - except TypeError: - return None - - try: - uname, passwd = smart_unicode(auth_parts[0]), smart_unicode(auth_parts[2]) - except DjangoUnicodeDecodeError: - return None - - user = authenticate(username=uname, password=passwd) - if user is not None and user.is_active: - return user - return None - - -class UserLoggedInAuthenticator(BaseAuthenticator): - """Use Django's built-in request session for authentication.""" - def authenticate(self, request): - 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': - # Temporarily replace request.POST with .RAW_CONTENT, - # so that we use our more generic request parsing - request._post = self.view.RAW_CONTENT - resp = CsrfViewMiddleware().process_view(request, None, (), {}) - del(request._post) - if resp is not None: # csrf failed - return None - return request.user - return None - - -#class DigestAuthentication(BaseAuthentication): -# pass -# -#class OAuthAuthentication(BaseAuthentication): -# pass diff --git a/djangorestframework/breadcrumbs.py b/djangorestframework/breadcrumbs.py deleted file mode 100644 index ba779dd0..00000000 --- a/djangorestframework/breadcrumbs.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.core.urlresolvers import resolve -from djangorestframework.description import get_name - -def get_breadcrumbs(url): - """Given a url returns a list of breadcrumbs, which are each a tuple of (name, url).""" - - def breadcrumbs_recursive(url, breadcrumbs_list): - """Add tuples of (name, url) to the breadcrumbs list, progressively chomping off parts of the url.""" - - # This is just like compsci 101 all over again... - try: - (view, unused_args, unused_kwargs) = resolve(url) - except: - pass - else: - if callable(view): - breadcrumbs_list.insert(0, (get_name(view), url)) - - if url == '': - # All done - return breadcrumbs_list - - elif url.endswith('/'): - # Drop trailing slash off the end and continue to try to resolve more breadcrumbs - return breadcrumbs_recursive(url.rstrip('/'), breadcrumbs_list) - - # Drop trailing non-slash off the end and continue to try to resolve more breadcrumbs - return breadcrumbs_recursive(url[:url.rfind('/') + 1], breadcrumbs_list) - - return breadcrumbs_recursive(url, []) - diff --git a/djangorestframework/compat.py b/djangorestframework/compat.py index 22b57186..98fbbb62 100644 --- a/djangorestframework/compat.py +++ b/djangorestframework/compat.py @@ -1,9 +1,24 @@ """Compatability module to provide support for backwards compatability with older versions of django/python""" +# cStringIO only if it's available +try: + import cStringIO as StringIO +except ImportError: + import StringIO + + +# parse_qs +try: + # python >= ? + from urlparse import parse_qs +except ImportError: + # python <= ? + from cgi import parse_qs + + # django.test.client.RequestFactory (Django >= 1.3) try: from django.test.client import RequestFactory - except ImportError: from django.test import Client from django.core.handlers.wsgi import WSGIRequest @@ -49,7 +64,7 @@ except ImportError: # django.views.generic.View (Django >= 1.3) try: from django.views.generic import View -except: +except ImportError: from django import http from django.utils.functional import update_wrapper # from django.utils.log import getLogger @@ -127,10 +142,47 @@ except: #) return http.HttpResponseNotAllowed(allowed_methods) -# parse_qs + try: - # python >= ? - from urlparse import parse_qs + import markdown + import re + + class CustomSetextHeaderProcessor(markdown.blockprocessors.BlockProcessor): + """Override markdown's SetextHeaderProcessor, so that ==== headers are

and ---- headers are

. + + We use

for the resource name.""" + + # Detect Setext-style header. Must be first 2 lines of block. + RE = re.compile(r'^.*?\n[=-]{3,}', re.MULTILINE) + + def test(self, parent, block): + return bool(self.RE.match(block)) + + def run(self, parent, blocks): + lines = blocks.pop(0).split('\n') + # Determine level. ``=`` is 1 and ``-`` is 2. + if lines[1].startswith('='): + level = 2 + else: + level = 3 + h = markdown.etree.SubElement(parent, 'h%d' % level) + h.text = lines[0].strip() + if len(lines) > 2: + # Block contains additional lines. Add to master blocks for later. + blocks.insert(0, '\n'.join(lines[2:])) + + def apply_markdown(text): + """Simple wrapper around markdown.markdown to apply our CustomSetextHeaderProcessor, + and also set the base level of '#' style headers to

.""" + extensions = ['headerid(level=2)'] + safe_mode = False, + output_format = markdown.DEFAULT_OUTPUT_FORMAT + + md = markdown.Markdown(extensions=markdown.load_extensions(extensions), + safe_mode=safe_mode, + output_format=output_format) + md.parser.blockprocessors['setextheader'] = CustomSetextHeaderProcessor(md.parser) + return md.convert(text) + except ImportError: - # python <= ? - from cgi import parse_qs \ No newline at end of file + apply_markdown = None \ No newline at end of file diff --git a/djangorestframework/description.py b/djangorestframework/description.py deleted file mode 100644 index f7145c0f..00000000 --- a/djangorestframework/description.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Get a descriptive name and description for a view, -based on class name and docstring, and override-able by 'name' and 'description' attributes""" -import re - -def get_name(view): - """Return a name for the view. - - If view has a name attribute, use that, otherwise use the view's class name, with 'CamelCaseNames' converted to 'Camel Case Names'.""" - if getattr(view, 'name', None) is not None: - return view.name - - if getattr(view, '__name__', None) is not None: - name = view.__name__ - elif getattr(view, '__class__', None) is not None: # TODO: should be able to get rid of this case once refactoring to 1.3 class views is complete - name = view.__class__.__name__ - else: - return '' - - return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', name).strip() - -def get_description(view): - """Provide a description for the view. - - By default this is the view's docstring with nice unindention applied.""" - if getattr(view, 'description', None) is not None: - return getattr(view, 'description') - - if getattr(view, '__doc__', None) is not None: - whitespace_counts = [len(line) - len(line.lstrip(' ')) for line in view.__doc__.splitlines()[1:] if line.lstrip()] - - if whitespace_counts: - whitespace_pattern = '^' + (' ' * min(whitespace_counts)) - return re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', view.__doc__) - - return view.__doc__ - - return '' \ No newline at end of file diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py deleted file mode 100644 index 87b3e94e..00000000 --- a/djangorestframework/emitters.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Emitters are used to serialize a Resource's output into specific media types. -django-rest-framework also provides HTML and PlainText emitters that help self-document the API, -by serializing the output along with documentation regarding the Resource, output status and headers, -and providing forms and links depending on the allowed methods, emitters and parsers on the Resource. -""" -from django import forms -from django.conf import settings -from django.template import RequestContext, loader -from django.utils import simplejson as json -from django import forms - -from djangorestframework.utils import dict2xml, url_resolves -from djangorestframework.markdownwrapper import apply_markdown -from djangorestframework.breadcrumbs import get_breadcrumbs -from djangorestframework.description import get_name, get_description -from djangorestframework import status - -from urllib import quote_plus -import string -import re -from decimal import Decimal - -# TODO: Rename verbose to something more appropriate -# TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default, -# and only have an emitter output anything if it explicitly provides support for that. - -class BaseEmitter(object): - """All emitters must extend this class, set the media_type attribute, and - override the emit() function.""" - media_type = None - - def __init__(self, resource): - self.resource = resource - - def emit(self, output=None, verbose=False): - """By default emit simply returns the ouput as-is. - Override this method to provide for other behaviour.""" - if output is None: - return '' - - return output - - -class TemplateEmitter(BaseEmitter): - """Provided for convienience. - Emit the output by simply rendering it with the given template.""" - media_type = None - template = None - - def emit(self, output=None, verbose=False): - if output is None: - return '' - - context = RequestContext(self.request, output) - return self.template.render(context) - - -class DocumentingTemplateEmitter(BaseEmitter): - """Base class for emitters used to self-document the API. - Implementing classes should extend this class and set the template attribute.""" - template = None - - def _get_content(self, resource, request, output): - """Get the content as if it had been emitted by a non-documenting emitter. - - (Typically this will be the content as it would have been if the Resource had been - requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)""" - - # Find the first valid emitter and emit the content. (Don't use another documenting emitter.) - emitters = [emitter for emitter in resource.emitters if not isinstance(emitter, DocumentingTemplateEmitter)] - if not emitters: - return '[No emitters were found]' - - content = emitters[0](resource).emit(output, verbose=True) - if not all(char in string.printable for char in content): - return '[%d bytes of binary content]' - - return content - - - def _get_form_instance(self, resource): - """Get a form, possibly bound to either the input or output data. - In the absence on of the Resource having an associated form then - provide a form that can be used to submit arbitrary content.""" - # Get the form instance if we have one bound to the input - #form_instance = resource.form_instance - # TODO! Reinstate this - - form_instance = getattr(resource, 'bound_form_instance', None) - - if not form_instance and hasattr(resource, 'get_bound_form'): - # Otherwise if we have a response that is valid against the form then use that - if resource.response.has_content_body: - try: - form_instance = resource.get_bound_form(resource.response.cleaned_content) - if form_instance and not form_instance.is_valid(): - form_instance = None - except: - form_instance = None - - # If we still don't have a form instance then try to get an unbound form - if not form_instance: - try: - form_instance = resource.get_bound_form() - except: - pass - - # If we still don't have a form instance then try to get an unbound form which can tunnel arbitrary content types - if not form_instance: - form_instance = self._get_generic_content_form(resource) - - return form_instance - - - def _get_generic_content_form(self, resource): - """Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms - (Which are typically application/x-www-form-urlencoded)""" - - # If we're not using content overloading there's no point in supplying a generic form, - # as the resource won't treat the form's value as the content of the request. - if not getattr(resource, 'USE_FORM_OVERLOADING', False): - return None - - # NB. http://jacobian.org/writing/dynamic-form-generation/ - class GenericContentForm(forms.Form): - def __init__(self, resource): - """We don't know the names of the fields we want to set until the point the form is instantiated, - as they are determined by the Resource the form is being created against. - Add the fields dynamically.""" - super(GenericContentForm, self).__init__() - - contenttype_choices = [(media_type, media_type) for media_type in resource.parsed_media_types] - initial_contenttype = resource.default_parser.media_type - - self.fields[resource.CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type', - choices=contenttype_choices, - initial=initial_contenttype) - self.fields[resource.CONTENT_PARAM] = forms.CharField(label='Content', - 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: - return None - - # Okey doke, let's do it - return GenericContentForm(resource) - - - def emit(self, output=None): - content = self._get_content(self.resource, self.resource.request, output) - form_instance = self._get_form_instance(self.resource) - - 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)) - else: - login_url = None - logout_url = None - - name = get_name(self.resource) - description = get_description(self.resource) - - markeddown = None - if apply_markdown: - try: - markeddown = apply_markdown(description) - except AttributeError: # TODO: possibly split the get_description / get_name into a mixin class - markeddown = None - - breadcrumb_list = get_breadcrumbs(self.resource.request.path) - - template = loader.get_template(self.template) - context = RequestContext(self.resource.request, { - 'content': content, - 'resource': self.resource, - 'request': self.resource.request, - 'response': self.resource.response, - 'description': description, - 'name': name, - 'markeddown': markeddown, - 'breadcrumblist': breadcrumb_list, - 'form': form_instance, - 'login_url': login_url, - 'logout_url': logout_url, - 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX - }) - - ret = template.render(context) - - return ret - - -class JSONEmitter(BaseEmitter): - """Emitter which serializes to JSON""" - media_type = 'application/json' - - def emit(self, output=None, verbose=False): - if output is None: - return '' - if verbose: - return json.dumps(output, indent=4, sort_keys=True) - return json.dumps(output) - - -class XMLEmitter(BaseEmitter): - """Emitter which serializes to XML.""" - media_type = 'application/xml' - - def emit(self, output=None, verbose=False): - if output is None: - return '' - return dict2xml(output) - - -class DocumentingHTMLEmitter(DocumentingTemplateEmitter): - """Emitter which provides a browsable HTML interface for an API. - See the examples listed in the django-rest-framework documentation to see this in actions.""" - media_type = 'text/html' - template = 'emitter.html' - - -class DocumentingXHTMLEmitter(DocumentingTemplateEmitter): - """Identical to DocumentingHTMLEmitter, except with an xhtml media type. - We need this to be listed in preference to xml in order to return HTML to WebKit based browsers, - given their Accept headers.""" - media_type = 'application/xhtml+xml' - template = 'emitter.html' - - -class DocumentingPlainTextEmitter(DocumentingTemplateEmitter): - """Emitter that serializes the output with the default emitter, but also provides plain-text - doumentation of the returned status and headers, and of the resource's name and description. - Useful for browsing an API with command line tools.""" - media_type = 'text/plain' - template = 'emitter.txt' - -DEFAULT_EMITTERS = ( JSONEmitter, - DocumentingHTMLEmitter, - DocumentingXHTMLEmitter, - DocumentingPlainTextEmitter, - XMLEmitter ) - - diff --git a/djangorestframework/markdownwrapper.py b/djangorestframework/markdownwrapper.py deleted file mode 100644 index 70512440..00000000 --- a/djangorestframework/markdownwrapper.py +++ /dev/null @@ -1,51 +0,0 @@ -"""If python-markdown is installed expose an apply_markdown(text) function, -to convert markeddown text into html. Otherwise just set apply_markdown to None. - -See: http://www.freewisdom.org/projects/python-markdown/ -""" - -__all__ = ['apply_markdown'] - -try: - import markdown - import re - - class CustomSetextHeaderProcessor(markdown.blockprocessors.BlockProcessor): - """Override markdown's SetextHeaderProcessor, so that ==== headers are

and ---- headers are

. - - We use

for the resource name.""" - - # Detect Setext-style header. Must be first 2 lines of block. - RE = re.compile(r'^.*?\n[=-]{3,}', re.MULTILINE) - - def test(self, parent, block): - return bool(self.RE.match(block)) - - def run(self, parent, blocks): - lines = blocks.pop(0).split('\n') - # Determine level. ``=`` is 1 and ``-`` is 2. - if lines[1].startswith('='): - level = 2 - else: - level = 3 - h = markdown.etree.SubElement(parent, 'h%d' % level) - h.text = lines[0].strip() - if len(lines) > 2: - # Block contains additional lines. Add to master blocks for later. - blocks.insert(0, '\n'.join(lines[2:])) - - def apply_markdown(text): - """Simple wrapper around markdown.markdown to apply our CustomSetextHeaderProcessor, - and also set the base level of '#' style headers to

.""" - extensions = ['headerid(level=2)'] - safe_mode = False, - output_format = markdown.DEFAULT_OUTPUT_FORMAT - - md = markdown.Markdown(extensions=markdown.load_extensions(extensions), - safe_mode=safe_mode, - output_format=output_format) - md.parser.blockprocessors['setextheader'] = CustomSetextHeaderProcessor(md.parser) - return md.convert(text) - -except: - apply_markdown = None \ No newline at end of file diff --git a/djangorestframework/mediatypes.py b/djangorestframework/mediatypes.py deleted file mode 100644 index 92d9264c..00000000 --- a/djangorestframework/mediatypes.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -Handling of media types, as found in HTTP Content-Type and Accept headers. - -See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 -""" - -from django.http.multipartparser import parse_header - - -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('/') - - def match(self, other): - """Return true if this MediaType satisfies the constraint of the given MediaType.""" - for key in other.params.keys(): - if key != 'q' and other.params[key] != self.params.get(key, None): - return False - - if other.sub_type != '*' and other.sub_type != self.sub_type: - return False - - if other.main_type != '*' and other.main_type != self.main_type: - return False - - return True - - def precedence(self): - """ - Return a precedence level for the media type given how specific it is. - """ - if self.main_type == '*': - return 1 - elif self.sub_type == '*': - return 2 - elif not self.params or self.params.keys() == ['q']: - return 3 - return 4 - - def quality(self): - """ - Return a quality level for the media type. - """ - try: - return Decimal(self.params.get('q', '1.0')) - except: - return Decimal(0) - - def score(self): - """ - Return an overall score for a given media type given it's quality and precedence. - """ - # 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) - - def __repr__(self): - return "" % (self.as_tuple(),) - - def __str__(self): - return unicode(self).encode('utf-8') - - def __unicode__(self): - return self.orig - diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 43b33f50..ebeee31a 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -1,4 +1,4 @@ -from djangorestframework.mediatypes import MediaType +from djangorestframework.utils.mediatypes import MediaType from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX from djangorestframework.response import ErrorResponse from djangorestframework.parsers import FormParser, MultipartParser @@ -397,7 +397,7 @@ class ResponseMixin(object): class AuthMixin(object): """Mixin class to provide authentication and permission checking.""" - authenticators = () + authentication = () permissions = () @property @@ -407,9 +407,9 @@ class AuthMixin(object): return self._auth def _authenticate(self): - for authenticator_cls in self.authenticators: - authenticator = authenticator_cls(self) - auth = authenticator.authenticate(self.request) + for authentication_cls in self.authentication: + authentication = authentication_cls(self) + auth = authentication.authenticate(self.request) if auth: return auth return None diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 96b29a66..6d6bd5ce 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -14,7 +14,7 @@ from django.utils import simplejson as json from djangorestframework.response import ErrorResponse from djangorestframework import status from djangorestframework.utils import as_tuple -from djangorestframework.mediatypes import MediaType +from djangorestframework.utils.mediatypes import MediaType from djangorestframework.compat import parse_qs diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py new file mode 100644 index 00000000..e53dc061 --- /dev/null +++ b/djangorestframework/renderers.py @@ -0,0 +1,243 @@ +"""Renderers are used to serialize a Resource's output into specific media types. +django-rest-framework also provides HTML and PlainText renderers that help self-document the API, +by serializing the output along with documentation regarding the Resource, output status and headers, +and providing forms and links depending on the allowed methods, renderers and parsers on the Resource. +""" +from django import forms +from django.conf import settings +from django.template import RequestContext, loader +from django.utils import simplejson as json +from django import forms + +from djangorestframework.utils import dict2xml, url_resolves +from djangorestframework.compat import apply_markdown +from djangorestframework.utils.breadcrumbs import get_breadcrumbs +from djangorestframework.utils.description import get_name, get_description +from djangorestframework import status + +from urllib import quote_plus +import string +import re +from decimal import Decimal + +# TODO: Rename verbose to something more appropriate +# TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default, +# and only have an renderer output anything if it explicitly provides support for that. + +class BaseRenderer(object): + """All renderers must extend this class, set the media_type attribute, and + override the render() function.""" + media_type = None + + def __init__(self, resource): + self.resource = resource + + def render(self, output=None, verbose=False): + """By default render simply returns the ouput as-is. + Override this method to provide for other behaviour.""" + if output is None: + return '' + + return output + + +class TemplateRenderer(BaseRenderer): + """Provided for convienience. + Emit the output by simply rendering it with the given template.""" + media_type = None + template = None + + def render(self, output=None, verbose=False): + if output is None: + return '' + + context = RequestContext(self.request, output) + return self.template.render(context) + + +class DocumentingTemplateRenderer(BaseRenderer): + """Base class for renderers used to self-document the API. + Implementing classes should extend this class and set the template attribute.""" + template = None + + def _get_content(self, resource, request, output): + """Get the content as if it had been renderted by a non-documenting renderer. + + (Typically this will be the content as it would have been if the Resource had been + requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)""" + + # Find the first valid renderer and render the content. (Don't use another documenting renderer.) + renderers = [renderer for renderer in resource.renderers if not isinstance(renderer, DocumentingTemplateRenderer)] + if not renderers: + return '[No renderers were found]' + + content = renderers[0](resource).render(output, verbose=True) + if not all(char in string.printable for char in content): + return '[%d bytes of binary content]' + + return content + + + def _get_form_instance(self, resource): + """Get a form, possibly bound to either the input or output data. + In the absence on of the Resource having an associated form then + provide a form that can be used to submit arbitrary content.""" + # Get the form instance if we have one bound to the input + #form_instance = resource.form_instance + # TODO! Reinstate this + + form_instance = getattr(resource, 'bound_form_instance', None) + + if not form_instance and hasattr(resource, 'get_bound_form'): + # Otherwise if we have a response that is valid against the form then use that + if resource.response.has_content_body: + try: + form_instance = resource.get_bound_form(resource.response.cleaned_content) + if form_instance and not form_instance.is_valid(): + form_instance = None + except: + form_instance = None + + # If we still don't have a form instance then try to get an unbound form + if not form_instance: + try: + form_instance = resource.get_bound_form() + except: + pass + + # If we still don't have a form instance then try to get an unbound form which can tunnel arbitrary content types + if not form_instance: + form_instance = self._get_generic_content_form(resource) + + return form_instance + + + def _get_generic_content_form(self, resource): + """Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms + (Which are typically application/x-www-form-urlencoded)""" + + # If we're not using content overloading there's no point in supplying a generic form, + # as the resource won't treat the form's value as the content of the request. + if not getattr(resource, 'USE_FORM_OVERLOADING', False): + return None + + # NB. http://jacobian.org/writing/dynamic-form-generation/ + class GenericContentForm(forms.Form): + def __init__(self, resource): + """We don't know the names of the fields we want to set until the point the form is instantiated, + as they are determined by the Resource the form is being created against. + Add the fields dynamically.""" + super(GenericContentForm, self).__init__() + + contenttype_choices = [(media_type, media_type) for media_type in resource.parsed_media_types] + initial_contenttype = resource.default_parser.media_type + + self.fields[resource.CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type', + choices=contenttype_choices, + initial=initial_contenttype) + self.fields[resource.CONTENT_PARAM] = forms.CharField(label='Content', + 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: + return None + + # Okey doke, let's do it + return GenericContentForm(resource) + + + def render(self, output=None): + content = self._get_content(self.resource, self.resource.request, output) + form_instance = self._get_form_instance(self.resource) + + 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)) + else: + login_url = None + logout_url = None + + name = get_name(self.resource) + description = get_description(self.resource) + + markeddown = None + if apply_markdown: + try: + markeddown = apply_markdown(description) + except AttributeError: # TODO: possibly split the get_description / get_name into a mixin class + markeddown = None + + breadcrumb_list = get_breadcrumbs(self.resource.request.path) + + template = loader.get_template(self.template) + context = RequestContext(self.resource.request, { + 'content': content, + 'resource': self.resource, + 'request': self.resource.request, + 'response': self.resource.response, + 'description': description, + 'name': name, + 'markeddown': markeddown, + 'breadcrumblist': breadcrumb_list, + 'form': form_instance, + 'login_url': login_url, + 'logout_url': logout_url, + 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX + }) + + ret = template.render(context) + + return ret + + +class JSONRenderer(BaseRenderer): + """Renderer which serializes to JSON""" + media_type = 'application/json' + + def render(self, output=None, verbose=False): + if output is None: + return '' + if verbose: + return json.dumps(output, indent=4, sort_keys=True) + return json.dumps(output) + + +class XMLRenderer(BaseRenderer): + """Renderer which serializes to XML.""" + media_type = 'application/xml' + + def render(self, output=None, verbose=False): + if output is None: + return '' + return dict2xml(output) + + +class DocumentingHTMLRenderer(DocumentingTemplateRenderer): + """Renderer which provides a browsable HTML interface for an API. + See the examples listed in the django-rest-framework documentation to see this in actions.""" + media_type = 'text/html' + template = 'renderer.html' + + +class DocumentingXHTMLRenderer(DocumentingTemplateRenderer): + """Identical to DocumentingHTMLRenderer, except with an xhtml media type. + We need this to be listed in preference to xml in order to return HTML to WebKit based browsers, + given their Accept headers.""" + media_type = 'application/xhtml+xml' + template = 'renderer.html' + + +class DocumentingPlainTextRenderer(DocumentingTemplateRenderer): + """Renderer that serializes the output with the default renderer, but also provides plain-text + doumentation of the returned status and headers, and of the resource's name and description. + Useful for browsing an API with command line tools.""" + media_type = 'text/plain' + template = 'renderer.txt' + +DEFAULT_RENDERERS = ( JSONRenderer, + DocumentingHTMLRenderer, + DocumentingXHTMLRenderer, + DocumentingPlainTextRenderer, + XMLRenderer ) + + diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index cb4d080c..7879da7c 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -4,7 +4,7 @@ from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View from djangorestframework.response import Response, ErrorResponse from djangorestframework.mixins import RequestMixin, ResponseMixin, AuthMixin -from djangorestframework import renderers, parsers, authenticators, permissions, validators, status +from djangorestframework import renderers, parsers, authentication, permissions, validators, status # TODO: Figure how out references and named urls need to work nicely @@ -37,8 +37,8 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): validators = ( validators.FormValidator, ) # List of all authenticating methods to attempt. - authenticators = ( authenticators.UserLoggedInAuthenticator, - authenticators.BasicAuthenticator ) + authentication = ( authentication.UserLoggedInAuthenticator, + authentication.BasicAuthenticator ) # List of all permissions required to access the resource permissions = () diff --git a/djangorestframework/templates/emitter.html b/djangorestframework/templates/emitter.html deleted file mode 100644 index 1931ad39..00000000 --- a/djangorestframework/templates/emitter.html +++ /dev/null @@ -1,127 +0,0 @@ -{% load urlize_quoted_links %}{% load add_query_param %} - - - - - - - Django REST framework - {{ name }} - - -
- - - - - -
- -
-

{{ name }}

-

{% if markeddown %}{% autoescape off %}{{ markeddown }}{% endautoescape %}{% else %}{{ description|linebreaksbr }}{% endif %}

-
-
{{ response.status }} {{ response.status_text }}{% autoescape off %}
-{% for key, val in response.headers.items %}{{ key }}: {{ val|urlize_quoted_links }}
-{% endfor %}
-{{ content|urlize_quoted_links }}
{% endautoescape %}
- - {% if 'GET' in resource.allowed_methods %} -
-
-

GET {{ name }}

-
- GET - {% for media_type in resource.emitted_media_types %} - {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} - [{{ media_type }}] - {% endwith %} - {% endfor %} -
-
-
- {% endif %} - - {% comment %} *** Only display the POST/PUT/DELETE forms if we have a bound form, and if method *** - *** tunneling via POST forms is enabled. *** - *** (We could display only the POST form if method tunneling is disabled, but I think *** - *** the user experience would be confusing, so we simply turn all forms off. *** {% endcomment %} - - {% if resource.METHOD_PARAM and form %} - {% if 'POST' in resource.allowed_methods %} -
-
-

POST {{ name }}

- {% csrf_token %} - {{ form.non_field_errors }} - {% for field in form %} -
- {{ field.label_tag }} - {{ field }} - {{ field.help_text }} - {{ field.errors }} -
- {% endfor %} -
- -
-
-
- {% endif %} - - {% if 'PUT' in resource.allowed_methods %} -
-
-

PUT {{ name }}

- - {% csrf_token %} - {{ form.non_field_errors }} - {% for field in form %} -
- {{ field.label_tag }} - {{ field }} - {{ field.help_text }} - {{ field.errors }} -
- {% endfor %} -
- -
-
-
- {% endif %} - - {% if 'DELETE' in resource.allowed_methods %} -
-
-

DELETE {{ name }}

- {% csrf_token %} - -
- -
-
-
- {% endif %} - {% endif %} -
-
-
- - \ No newline at end of file diff --git a/djangorestframework/templates/emitter.txt b/djangorestframework/templates/emitter.txt deleted file mode 100644 index 5be8c117..00000000 --- a/djangorestframework/templates/emitter.txt +++ /dev/null @@ -1,8 +0,0 @@ -{{ name }} - -{{ description }} - -{% autoescape off %}HTTP/1.0 {{ response.status }} {{ response.status_text }} -{% for key, val in response.headers.items %}{{ key }}: {{ val }} -{% endfor %} -{{ content }}{% endautoescape %} diff --git a/djangorestframework/templates/renderer.html b/djangorestframework/templates/renderer.html new file mode 100644 index 00000000..105ea0a2 --- /dev/null +++ b/djangorestframework/templates/renderer.html @@ -0,0 +1,127 @@ +{% load urlize_quoted_links %}{% load add_query_param %} + + + + + + + Django REST framework - {{ name }} + + +
+ + + + + +
+ +
+

{{ name }}

+

{% if markeddown %}{% autoescape off %}{{ markeddown }}{% endautoescape %}{% else %}{{ description|linebreaksbr }}{% endif %}

+
+
{{ response.status }} {{ response.status_text }}{% autoescape off %}
+{% for key, val in response.headers.items %}{{ key }}: {{ val|urlize_quoted_links }}
+{% endfor %}
+{{ content|urlize_quoted_links }}
{% endautoescape %}
+ + {% if 'GET' in resource.allowed_methods %} +
+
+

GET {{ name }}

+
+ GET + {% for media_type in resource.renderted_media_types %} + {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} + [{{ media_type }}] + {% endwith %} + {% endfor %} +
+
+
+ {% endif %} + + {% comment %} *** Only display the POST/PUT/DELETE forms if we have a bound form, and if method *** + *** tunneling via POST forms is enabled. *** + *** (We could display only the POST form if method tunneling is disabled, but I think *** + *** the user experience would be confusing, so we simply turn all forms off. *** {% endcomment %} + + {% if resource.METHOD_PARAM and form %} + {% if 'POST' in resource.allowed_methods %} +
+
+

POST {{ name }}

+ {% csrf_token %} + {{ form.non_field_errors }} + {% for field in form %} +
+ {{ field.label_tag }} + {{ field }} + {{ field.help_text }} + {{ field.errors }} +
+ {% endfor %} +
+ +
+
+
+ {% endif %} + + {% if 'PUT' in resource.allowed_methods %} +
+
+

PUT {{ name }}

+ + {% csrf_token %} + {{ form.non_field_errors }} + {% for field in form %} +
+ {{ field.label_tag }} + {{ field }} + {{ field.help_text }} + {{ field.errors }} +
+ {% endfor %} +
+ +
+
+
+ {% endif %} + + {% if 'DELETE' in resource.allowed_methods %} +
+
+

DELETE {{ name }}

+ {% csrf_token %} + +
+ +
+
+
+ {% endif %} + {% endif %} +
+
+
+ + \ No newline at end of file diff --git a/djangorestframework/templates/renderer.txt b/djangorestframework/templates/renderer.txt new file mode 100644 index 00000000..5be8c117 --- /dev/null +++ b/djangorestframework/templates/renderer.txt @@ -0,0 +1,8 @@ +{{ name }} + +{{ description }} + +{% autoescape off %}HTTP/1.0 {{ response.status }} {{ response.status_text }} +{% for key, val in response.headers.items %}{{ key }}: {{ val }} +{% endfor %} +{{ content }}{% endautoescape %} diff --git a/djangorestframework/tests/breadcrumbs.py b/djangorestframework/tests/breadcrumbs.py index 724f2ff5..2f9a7e9d 100644 --- a/djangorestframework/tests/breadcrumbs.py +++ b/djangorestframework/tests/breadcrumbs.py @@ -1,6 +1,6 @@ from django.conf.urls.defaults import patterns, url from django.test import TestCase -from djangorestframework.breadcrumbs import get_breadcrumbs +from djangorestframework.utils.breadcrumbs import get_breadcrumbs from djangorestframework.resource import Resource class Root(Resource): diff --git a/djangorestframework/tests/description.py b/djangorestframework/tests/description.py index 3e3f7b21..d34e2d11 100644 --- a/djangorestframework/tests/description.py +++ b/djangorestframework/tests/description.py @@ -1,7 +1,7 @@ from django.test import TestCase from djangorestframework.resource import Resource -from djangorestframework.markdownwrapper import apply_markdown -from djangorestframework.description import get_name, get_description +from djangorestframework.compat import apply_markdown +from djangorestframework.utils.description import get_name, get_description # We check that docstrings get nicely un-indented. DESCRIPTION = """an example docstring diff --git a/djangorestframework/tests/emitters.py b/djangorestframework/tests/emitters.py deleted file mode 100644 index 21a7eb95..00000000 --- a/djangorestframework/tests/emitters.py +++ /dev/null @@ -1,76 +0,0 @@ -from django.conf.urls.defaults import patterns, url -from django import http -from django.test import TestCase -from djangorestframework.compat import View -from djangorestframework.emitters import BaseEmitter -from djangorestframework.mixins import ResponseMixin -from djangorestframework.response import Response - -DUMMYSTATUS = 200 -DUMMYCONTENT = 'dummycontent' - -EMITTER_A_SERIALIZER = lambda x: 'Emitter A: %s' % x -EMITTER_B_SERIALIZER = lambda x: 'Emitter B: %s' % x - -class MockView(ResponseMixin, View): - def get(self, request): - response = Response(DUMMYSTATUS, DUMMYCONTENT) - return self.emit(response) - -class EmitterA(BaseEmitter): - media_type = 'mock/emittera' - - def emit(self, output, verbose=False): - return EMITTER_A_SERIALIZER(output) - -class EmitterB(BaseEmitter): - media_type = 'mock/emitterb' - - def emit(self, output, verbose=False): - return EMITTER_B_SERIALIZER(output) - - -urlpatterns = patterns('', - url(r'^$', MockView.as_view(emitters=[EmitterA, EmitterB])), -) - - -class EmitterIntegrationTests(TestCase): - """End-to-end testing of emitters using an EmitterMixin on a generic view.""" - - urls = 'djangorestframework.tests.emitters' - - def test_default_emitter_serializes_content(self): - """If the Accept header is not set the default emitter should serialize the response.""" - resp = self.client.get('/') - self.assertEquals(resp['Content-Type'], EmitterA.media_type) - self.assertEquals(resp.content, EMITTER_A_SERIALIZER(DUMMYCONTENT)) - self.assertEquals(resp.status_code, DUMMYSTATUS) - - def test_default_emitter_serializes_content_on_accept_any(self): - """If the Accept header is set to */* the default emitter should serialize the response.""" - resp = self.client.get('/', HTTP_ACCEPT='*/*') - self.assertEquals(resp['Content-Type'], EmitterA.media_type) - self.assertEquals(resp.content, EMITTER_A_SERIALIZER(DUMMYCONTENT)) - self.assertEquals(resp.status_code, DUMMYSTATUS) - - def test_specified_emitter_serializes_content_default_case(self): - """If the Accept header is set the specified emitter should serialize the response. - (In this case we check that works for the default emitter)""" - resp = self.client.get('/', HTTP_ACCEPT=EmitterA.media_type) - self.assertEquals(resp['Content-Type'], EmitterA.media_type) - self.assertEquals(resp.content, EMITTER_A_SERIALIZER(DUMMYCONTENT)) - self.assertEquals(resp.status_code, DUMMYSTATUS) - - def test_specified_emitter_serializes_content_non_default_case(self): - """If the Accept header is set the specified emitter should serialize the response. - (In this case we check that works for a non-default emitter)""" - resp = self.client.get('/', HTTP_ACCEPT=EmitterB.media_type) - self.assertEquals(resp['Content-Type'], EmitterB.media_type) - self.assertEquals(resp.content, EMITTER_B_SERIALIZER(DUMMYCONTENT)) - self.assertEquals(resp.status_code, DUMMYSTATUS) - - def test_unsatisfiable_accept_header_on_request_returns_406_status(self): - """If the Accept header is unsatisfiable we should return a 406 Not Acceptable response.""" - resp = self.client.get('/', HTTP_ACCEPT='foo/bar') - self.assertEquals(resp.status_code, 406) \ No newline at end of file diff --git a/djangorestframework/tests/parsers.py b/djangorestframework/tests/parsers.py index 4753f6f3..00ebc812 100644 --- a/djangorestframework/tests/parsers.py +++ b/djangorestframework/tests/parsers.py @@ -82,7 +82,7 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.parsers import MultipartParser from djangorestframework.resource import Resource -from djangorestframework.mediatypes import MediaType +from djangorestframework.utils.mediatypes import MediaType from StringIO import StringIO def encode_multipart_formdata(fields, files): diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py new file mode 100644 index 00000000..df0d9c8d --- /dev/null +++ b/djangorestframework/tests/renderers.py @@ -0,0 +1,76 @@ +from django.conf.urls.defaults import patterns, url +from django import http +from django.test import TestCase +from djangorestframework.compat import View +from djangorestframework.renderers import BaseRenderer +from djangorestframework.mixins import ResponseMixin +from djangorestframework.response import Response + +DUMMYSTATUS = 200 +DUMMYCONTENT = 'dummycontent' + +RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x +RENDERER_B_SERIALIZER = lambda x: 'Renderer B: %s' % x + +class MockView(ResponseMixin, View): + def get(self, request): + response = Response(DUMMYSTATUS, DUMMYCONTENT) + return self.render(response) + +class RendererA(BaseRenderer): + media_type = 'mock/renderera' + + def render(self, output, verbose=False): + return RENDERER_A_SERIALIZER(output) + +class RendererB(BaseRenderer): + media_type = 'mock/rendererb' + + def render(self, output, verbose=False): + return RENDERER_B_SERIALIZER(output) + + +urlpatterns = patterns('', + url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])), +) + + +class RendererIntegrationTests(TestCase): + """End-to-end testing of renderers using an RendererMixin on a generic view.""" + + urls = 'djangorestframework.tests.renderers' + + def test_default_renderer_serializes_content(self): + """If the Accept header is not set the default renderer should serialize the response.""" + resp = self.client.get('/') + self.assertEquals(resp['Content-Type'], RendererA.media_type) + self.assertEquals(resp.content, RENDERER_A_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_default_renderer_serializes_content_on_accept_any(self): + """If the Accept header is set to */* the default renderer should serialize the response.""" + resp = self.client.get('/', HTTP_ACCEPT='*/*') + self.assertEquals(resp['Content-Type'], RendererA.media_type) + self.assertEquals(resp.content, RENDERER_A_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_specified_renderer_serializes_content_default_case(self): + """If the Accept header is set the specified renderer should serialize the response. + (In this case we check that works for the default renderer)""" + resp = self.client.get('/', HTTP_ACCEPT=RendererA.media_type) + self.assertEquals(resp['Content-Type'], RendererA.media_type) + self.assertEquals(resp.content, RENDERER_A_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_specified_renderer_serializes_content_non_default_case(self): + """If the Accept header is set the specified renderer should serialize the response. + (In this case we check that works for a non-default renderer)""" + resp = self.client.get('/', HTTP_ACCEPT=RendererB.media_type) + self.assertEquals(resp['Content-Type'], RendererB.media_type) + self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_unsatisfiable_accept_header_on_request_returns_406_status(self): + """If the Accept header is unsatisfiable we should return a 406 Not Acceptable response.""" + resp = self.client.get('/', HTTP_ACCEPT='foo/bar') + self.assertEquals(resp.status_code, 406) \ No newline at end of file diff --git a/djangorestframework/utils.py b/djangorestframework/utils.py deleted file mode 100644 index f60bdee4..00000000 --- a/djangorestframework/utils.py +++ /dev/null @@ -1,159 +0,0 @@ -import re -import xml.etree.ElementTree as ET -from django.utils.encoding import smart_unicode -from django.utils.xmlutils import SimplerXMLGenerator -from django.core.urlresolvers import resolve -from django.conf import settings -try: - import cStringIO as StringIO -except ImportError: - import StringIO - - -#def admin_media_prefix(request): -# """Adds the ADMIN_MEDIA_PREFIX to the request context.""" -# return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX} - -MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') - -def as_tuple(obj): - """Given obj return a tuple""" - if obj is None: - return () - elif isinstance(obj, list): - return tuple(obj) - elif isinstance(obj, tuple): - return obj - return (obj,) - - -def url_resolves(url): - """Return True if the given URL is mapped to a view in the urlconf, False otherwise.""" - try: - resolve(url) - except: - return False - return True - - -# From http://www.koders.com/python/fidB6E125C586A6F49EAC38992CF3AFDAAE35651975.aspx?s=mdef:xml -#class object_dict(dict): -# """object view of dict, you can -# >>> a = object_dict() -# >>> a.fish = 'fish' -# >>> a['fish'] -# 'fish' -# >>> a['water'] = 'water' -# >>> a.water -# 'water' -# >>> a.test = {'value': 1} -# >>> a.test2 = object_dict({'name': 'test2', 'value': 2}) -# >>> a.test, a.test2.name, a.test2.value -# (1, 'test2', 2) -# """ -# def __init__(self, initd=None): -# if initd is None: -# initd = {} -# dict.__init__(self, initd) -# -# def __getattr__(self, item): -# d = self.__getitem__(item) -# # if value is the only key in object, you can omit it -# if isinstance(d, dict) and 'value' in d and len(d) == 1: -# return d['value'] -# else: -# return d -# -# def __setattr__(self, item, value): -# self.__setitem__(item, value) - - -# From xml2dict -class XML2Dict(object): - - def __init__(self): - pass - - def _parse_node(self, node): - node_tree = {} - # Save attrs and text, hope there will not be a child with same name - if node.text: - node_tree = node.text - for (k,v) in node.attrib.items(): - k,v = self._namespace_split(k, v) - node_tree[k] = v - #Save childrens - for child in node.getchildren(): - tag, tree = self._namespace_split(child.tag, self._parse_node(child)) - if tag not in node_tree: # the first time, so store it in dict - node_tree[tag] = tree - continue - old = node_tree[tag] - if not isinstance(old, list): - node_tree.pop(tag) - node_tree[tag] = [old] # multi times, so change old dict to a list - node_tree[tag].append(tree) # add the new one - - return node_tree - - - def _namespace_split(self, tag, value): - """ - Split the tag '{http://cs.sfsu.edu/csc867/myscheduler}patients' - ns = http://cs.sfsu.edu/csc867/myscheduler - name = patients - """ - result = re.compile("\{(.*)\}(.*)").search(tag) - if result: - value.namespace, tag = result.groups() - return (tag, value) - - def parse(self, file): - """parse a xml file to a dict""" - f = open(file, 'r') - return self.fromstring(f.read()) - - def fromstring(self, s): - """parse a string""" - t = ET.fromstring(s) - unused_root_tag, root_tree = self._namespace_split(t.tag, self._parse_node(t)) - return root_tree - - -def xml2dict(input): - return XML2Dict().fromstring(input) - - -# Piston: -class XMLRenderer(): - def _to_xml(self, xml, data): - if isinstance(data, (list, tuple)): - for item in data: - xml.startElement("list-item", {}) - self._to_xml(xml, item) - xml.endElement("list-item") - - elif isinstance(data, dict): - for key, value in data.iteritems(): - xml.startElement(key, {}) - self._to_xml(xml, value) - xml.endElement(key) - - else: - xml.characters(smart_unicode(data)) - - def dict2xml(self, data): - stream = StringIO.StringIO() - - xml = SimplerXMLGenerator(stream, "utf-8") - xml.startDocument() - xml.startElement("root", {}) - - self._to_xml(xml, data) - - xml.endElement("root") - xml.endDocument() - return stream.getvalue() - -def dict2xml(input): - return XMLRenderer().dict2xml(input) diff --git a/djangorestframework/utils/__init__.py b/djangorestframework/utils/__init__.py new file mode 100644 index 00000000..9dc769be --- /dev/null +++ b/djangorestframework/utils/__init__.py @@ -0,0 +1,158 @@ +from django.utils.encoding import smart_unicode +from django.utils.xmlutils import SimplerXMLGenerator +from django.core.urlresolvers import resolve +from django.conf import settings + +from djangorestframework.compat import StringIO + +import re +import xml.etree.ElementTree as ET + + +#def admin_media_prefix(request): +# """Adds the ADMIN_MEDIA_PREFIX to the request context.""" +# return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX} + +MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') + +def as_tuple(obj): + """Given obj return a tuple""" + if obj is None: + return () + elif isinstance(obj, list): + return tuple(obj) + elif isinstance(obj, tuple): + return obj + return (obj,) + + +def url_resolves(url): + """Return True if the given URL is mapped to a view in the urlconf, False otherwise.""" + try: + resolve(url) + except: + return False + return True + + +# From http://www.koders.com/python/fidB6E125C586A6F49EAC38992CF3AFDAAE35651975.aspx?s=mdef:xml +#class object_dict(dict): +# """object view of dict, you can +# >>> a = object_dict() +# >>> a.fish = 'fish' +# >>> a['fish'] +# 'fish' +# >>> a['water'] = 'water' +# >>> a.water +# 'water' +# >>> a.test = {'value': 1} +# >>> a.test2 = object_dict({'name': 'test2', 'value': 2}) +# >>> a.test, a.test2.name, a.test2.value +# (1, 'test2', 2) +# """ +# def __init__(self, initd=None): +# if initd is None: +# initd = {} +# dict.__init__(self, initd) +# +# def __getattr__(self, item): +# d = self.__getitem__(item) +# # if value is the only key in object, you can omit it +# if isinstance(d, dict) and 'value' in d and len(d) == 1: +# return d['value'] +# else: +# return d +# +# def __setattr__(self, item, value): +# self.__setitem__(item, value) + + +# From xml2dict +class XML2Dict(object): + + def __init__(self): + pass + + def _parse_node(self, node): + node_tree = {} + # Save attrs and text, hope there will not be a child with same name + if node.text: + node_tree = node.text + for (k,v) in node.attrib.items(): + k,v = self._namespace_split(k, v) + node_tree[k] = v + #Save childrens + for child in node.getchildren(): + tag, tree = self._namespace_split(child.tag, self._parse_node(child)) + if tag not in node_tree: # the first time, so store it in dict + node_tree[tag] = tree + continue + old = node_tree[tag] + if not isinstance(old, list): + node_tree.pop(tag) + node_tree[tag] = [old] # multi times, so change old dict to a list + node_tree[tag].append(tree) # add the new one + + return node_tree + + + def _namespace_split(self, tag, value): + """ + Split the tag '{http://cs.sfsu.edu/csc867/myscheduler}patients' + ns = http://cs.sfsu.edu/csc867/myscheduler + name = patients + """ + result = re.compile("\{(.*)\}(.*)").search(tag) + if result: + value.namespace, tag = result.groups() + return (tag, value) + + def parse(self, file): + """parse a xml file to a dict""" + f = open(file, 'r') + return self.fromstring(f.read()) + + def fromstring(self, s): + """parse a string""" + t = ET.fromstring(s) + unused_root_tag, root_tree = self._namespace_split(t.tag, self._parse_node(t)) + return root_tree + + +def xml2dict(input): + return XML2Dict().fromstring(input) + + +# Piston: +class XMLRenderer(): + def _to_xml(self, xml, data): + if isinstance(data, (list, tuple)): + for item in data: + xml.startElement("list-item", {}) + self._to_xml(xml, item) + xml.endElement("list-item") + + elif isinstance(data, dict): + for key, value in data.iteritems(): + xml.startElement(key, {}) + self._to_xml(xml, value) + xml.endElement(key) + + else: + xml.characters(smart_unicode(data)) + + def dict2xml(self, data): + stream = StringIO.StringIO() + + xml = SimplerXMLGenerator(stream, "utf-8") + xml.startDocument() + xml.startElement("root", {}) + + self._to_xml(xml, data) + + xml.endElement("root") + xml.endDocument() + return stream.getvalue() + +def dict2xml(input): + return XMLRenderer().dict2xml(input) diff --git a/djangorestframework/utils/breadcrumbs.py b/djangorestframework/utils/breadcrumbs.py new file mode 100644 index 00000000..1e604efc --- /dev/null +++ b/djangorestframework/utils/breadcrumbs.py @@ -0,0 +1,30 @@ +from django.core.urlresolvers import resolve +from djangorestframework.utils.description import get_name + +def get_breadcrumbs(url): + """Given a url returns a list of breadcrumbs, which are each a tuple of (name, url).""" + + def breadcrumbs_recursive(url, breadcrumbs_list): + """Add tuples of (name, url) to the breadcrumbs list, progressively chomping off parts of the url.""" + + try: + (view, unused_args, unused_kwargs) = resolve(url) + except: + pass + else: + if callable(view): + breadcrumbs_list.insert(0, (get_name(view), url)) + + if url == '': + # All done + return breadcrumbs_list + + elif url.endswith('/'): + # Drop trailing slash off the end and continue to try to resolve more breadcrumbs + return breadcrumbs_recursive(url.rstrip('/'), breadcrumbs_list) + + # Drop trailing non-slash off the end and continue to try to resolve more breadcrumbs + return breadcrumbs_recursive(url[:url.rfind('/') + 1], breadcrumbs_list) + + return breadcrumbs_recursive(url, []) + diff --git a/djangorestframework/utils/description.py b/djangorestframework/utils/description.py new file mode 100644 index 00000000..f7145c0f --- /dev/null +++ b/djangorestframework/utils/description.py @@ -0,0 +1,37 @@ +"""Get a descriptive name and description for a view, +based on class name and docstring, and override-able by 'name' and 'description' attributes""" +import re + +def get_name(view): + """Return a name for the view. + + If view has a name attribute, use that, otherwise use the view's class name, with 'CamelCaseNames' converted to 'Camel Case Names'.""" + if getattr(view, 'name', None) is not None: + return view.name + + if getattr(view, '__name__', None) is not None: + name = view.__name__ + elif getattr(view, '__class__', None) is not None: # TODO: should be able to get rid of this case once refactoring to 1.3 class views is complete + name = view.__class__.__name__ + else: + return '' + + return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', name).strip() + +def get_description(view): + """Provide a description for the view. + + By default this is the view's docstring with nice unindention applied.""" + if getattr(view, 'description', None) is not None: + return getattr(view, 'description') + + if getattr(view, '__doc__', None) is not None: + whitespace_counts = [len(line) - len(line.lstrip(' ')) for line in view.__doc__.splitlines()[1:] if line.lstrip()] + + if whitespace_counts: + whitespace_pattern = '^' + (' ' * min(whitespace_counts)) + return re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', view.__doc__) + + return view.__doc__ + + return '' \ No newline at end of file diff --git a/djangorestframework/utils/mediatypes.py b/djangorestframework/utils/mediatypes.py new file mode 100644 index 00000000..92d9264c --- /dev/null +++ b/djangorestframework/utils/mediatypes.py @@ -0,0 +1,78 @@ +""" +Handling of media types, as found in HTTP Content-Type and Accept headers. + +See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 +""" + +from django.http.multipartparser import parse_header + + +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('/') + + def match(self, other): + """Return true if this MediaType satisfies the constraint of the given MediaType.""" + for key in other.params.keys(): + if key != 'q' and other.params[key] != self.params.get(key, None): + return False + + if other.sub_type != '*' and other.sub_type != self.sub_type: + return False + + if other.main_type != '*' and other.main_type != self.main_type: + return False + + return True + + def precedence(self): + """ + Return a precedence level for the media type given how specific it is. + """ + if self.main_type == '*': + return 1 + elif self.sub_type == '*': + return 2 + elif not self.params or self.params.keys() == ['q']: + return 3 + return 4 + + def quality(self): + """ + Return a quality level for the media type. + """ + try: + return Decimal(self.params.get('q', '1.0')) + except: + return Decimal(0) + + def score(self): + """ + Return an overall score for a given media type given it's quality and precedence. + """ + # 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) + + def __repr__(self): + return "" % (self.as_tuple(),) + + def __str__(self): + return unicode(self).encode('utf-8') + + def __unicode__(self): + return self.orig + -- cgit v1.2.3