diff options
Diffstat (limited to 'djangorestframework')
| -rw-r--r-- | djangorestframework/__init__.py | 0 | ||||
| -rw-r--r-- | djangorestframework/authenticators.py | 44 | ||||
| -rw-r--r-- | djangorestframework/emitters.py | 216 | ||||
| -rw-r--r-- | djangorestframework/modelresource.py | 420 | ||||
| -rw-r--r-- | djangorestframework/parsers.py | 89 | ||||
| -rw-r--r-- | djangorestframework/resource.py | 451 | ||||
| -rw-r--r-- | djangorestframework/response.py | 125 | ||||
| -rw-r--r-- | djangorestframework/templates/emitter.html | 108 | ||||
| -rw-r--r-- | djangorestframework/templates/emitter.txt | 8 | ||||
| -rw-r--r-- | djangorestframework/templatetags/__init__.py | 0 | ||||
| -rw-r--r-- | djangorestframework/templatetags/add_query_param.py | 17 | ||||
| -rw-r--r-- | djangorestframework/templatetags/urlize_quoted_links.py | 100 | ||||
| -rw-r--r-- | djangorestframework/utils.py | 179 | 
13 files changed, 1757 insertions, 0 deletions
| diff --git a/djangorestframework/__init__.py b/djangorestframework/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/djangorestframework/__init__.py diff --git a/djangorestframework/authenticators.py b/djangorestframework/authenticators.py new file mode 100644 index 00000000..8de182de --- /dev/null +++ b/djangorestframework/authenticators.py @@ -0,0 +1,44 @@ +from django.contrib.auth import authenticate +import base64 + +class BaseAuthenticator(object): +    """All authenticators should extend BaseAuthenticator.""" + +    def __init__(self, resource): +        """Initialise the authenticator with the Resource instance as state, +        in case the authenticator needs to access any metadata on the Resource object.""" +        self.resource = resource + +    def authenticate(self, request): +        """Authenticate the request and return the authentication context or None. + +        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 passed to the method calls eg Resource.get(request, 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): +        if 'HTTP_AUTHORIZATION' in request.META: +            auth = request.META['HTTP_AUTHORIZATION'].split() +            if len(auth) == 2 and auth[0].lower() == "basic": +                uname, passwd = base64.b64decode(auth[1]).split(':') +                user = authenticate(username=uname, password=passwd) +                if user is not None and user.is_active: +                    return user +        return None +                 + +class UserLoggedInAuthenticator(BaseAuthenticator): +    """Use Djagno's built-in request session for authentication.""" +    def authenticate(self, request): +        if request.user and request.user.is_active: +            return request.user +        return None +     diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py new file mode 100644 index 00000000..a69407f1 --- /dev/null +++ b/djangorestframework/emitters.py @@ -0,0 +1,216 @@ +"""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.conf import settings +from django.template import RequestContext, loader +from django import forms + +from djangorestframework.response import NoContent +from djangorestframework.utils import dict2xml, url_resolves + +from urllib import quote_plus +import string +try: +    import json +except ImportError: +    import simplejson as json + + + +# TODO: Rename verbose to something more appropriate +# TODO: NoContent 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=NoContent, verbose=False): +        """By default emit simply returns the ouput as-is. +        Override this method to provide for other behaviour.""" +        if output is NoContent: +            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=NoContent, verbose=False): +        if output is NoContent: +            return '' + +        context = RequestContext(self.resource.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, 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 + +        # Otherwise if this isn't an error response +        # then attempt to get a form bound to the response object +        if not form_instance and resource.response.has_content_body: +            try: +                form_instance = resource.get_form(resource.response.raw_content) +                if form_instance: +                    form_instance.is_valid() +            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 = self.resource.get_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)""" + +        # 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=NoContent): +        content = self._get_content(self.resource, 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 + +        template = loader.get_template(self.template) +        context = RequestContext(self.resource.request, { +            'content': content, +            'resource': self.resource, +            'request': self.resource.request, +            'response': self.resource.response, +            'form': form_instance, +            'login_url': login_url, +            'logout_url': logout_url, +        }) +         +        ret = template.render(context) + +        # Munge DELETE Response code to allow us to return content +        # (Do this *after* we've rendered the template so that we include the normal deletion response code in the output) +        if self.resource.response.status == 204: +            self.resource.response.status = 200 + +        return ret + + +class JSONEmitter(BaseEmitter): +    """Emitter which serializes to JSON""" +    media_type = 'application/json' + +    def emit(self, output=NoContent, verbose=False): +        if output is NoContent: +            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=NoContent, verbose=False): +        if output is NoContent: +            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' + + diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py new file mode 100644 index 00000000..a9605d4a --- /dev/null +++ b/djangorestframework/modelresource.py @@ -0,0 +1,420 @@ +from django.forms import ModelForm +from django.db.models.query import QuerySet +from django.db.models import Model + +from djangorestframework.response import status, Response, ResponseException +from djangorestframework.resource import Resource + +import decimal +import inspect +import re + + +class ModelResource(Resource): +    """A specialized type of Resource, for resources that map directly to a Django Model. +    Useful things this provides: + +    0. Default input validation based on ModelForms. +    1. Nice serialization of returned Models and QuerySets. +    2. A default set of create/read/update/delete operations.""" +     +    # The model attribute refers to the Django Model which this Resource maps to. +    # (The Model's class, rather than an instance of the Model) +    model = None +     +    # By default the set of returned fields will be the set of: +    # +    # 0. All the fields on the model, excluding 'id'. +    # 1. All the properties on the model. +    # 2. The absolute_url of the model, if a get_absolute_url method exists for the model. +    # +    # If you wish to override this behaviour, +    # you should explicitly set the fields attribute on your class. +    fields = None +     +    # By default the form used with be a ModelForm for self.model +    # If you wish to override this behaviour or provide a sub-classed ModelForm +    # you should explicitly set the form attribute on your class. +    form = None +     +    # By default the set of input fields will be the same as the set of output fields +    # If you wish to override this behaviour you should explicitly set the +    # form_fields attribute on your class.  +    form_fields = None + + +    def get_form(self, content=None): +        """Return a form that may be used in validation and/or rendering an html emitter""" +        if self.form: +            return super(self.__class__, self).get_form(content) + +        elif self.model: + +            class NewModelForm(ModelForm): +                class Meta: +                    model = self.model +                    fields = self.form_fields if self.form_fields else None + +            if content and isinstance(content, Model): +                return NewModelForm(instance=content) +            elif content: +                return NewModelForm(content) +             +            return NewModelForm() + +        return None + + +    def cleanup_request(self, data, form_instance): +        """Override cleanup_request to drop read-only fields from the input prior to validation. +        This ensures that we don't error out with 'non-existent field' when these fields are supplied, +        and allows for a pragmatic approach to resources which include read-only elements. + +        I would actually like to be strict and verify the value of correctness of the values in these fields, +        although that gets tricky as it involves validating at the point that we get the model instance. +         +        See here for another example of this approach: +        http://fedoraproject.org/wiki/Cloud_APIs_REST_Style_Guide +        https://www.redhat.com/archives/rest-practices/2010-April/thread.html#00041""" +        read_only_fields = set(self.fields) - set(self.form_instance.fields) +        input_fields = set(data.keys()) + +        clean_data = {} +        for key in input_fields - read_only_fields: +            clean_data[key] = data[key] + +        return super(ModelResource, self).cleanup_request(clean_data, form_instance) + + +    def cleanup_response(self, data): +        """A munging of Piston's pre-serialization.  Returns a dict""" + +        def _any(thing, fields=()): +            """ +            Dispatch, all types are routed through here. +            """ +            ret = None +             +            if isinstance(thing, QuerySet): +                ret = _qs(thing, fields=fields) +            elif isinstance(thing, (tuple, list)): +                ret = _list(thing) +            elif isinstance(thing, dict): +                ret = _dict(thing) +            elif isinstance(thing, int): +                ret = thing +            elif isinstance(thing, bool): +                ret = thing +            elif isinstance(thing, type(None)): +                ret = thing +            elif isinstance(thing, decimal.Decimal): +                ret = str(thing) +            elif isinstance(thing, Model): +                ret = _model(thing, fields=fields) +            #elif isinstance(thing, HttpResponse):    TRC +            #    raise HttpStatusCode(thing) +            elif inspect.isfunction(thing): +                if not inspect.getargspec(thing)[0]: +                    ret = _any(thing()) +            elif hasattr(thing, '__emittable__'): +                f = thing.__emittable__ +                if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: +                    ret = _any(f()) +            else: +                ret = str(thing)  # TRC  TODO: Change this back! + +            return ret + +        def _fk(data, field): +            """ +            Foreign keys. +            """ +            return _any(getattr(data, field.name)) +         +        def _related(data, fields=()): +            """ +            Foreign keys. +            """ +            return [ _model(m, fields) for m in data.iterator() ] +         +        def _m2m(data, field, fields=()): +            """ +            Many to many (re-route to `_model`.) +            """ +            return [ _model(m, fields) for m in getattr(data, field.name).iterator() ] +         + +        def _method_fields(data, fields): +            if not data: +                return { } +     +            has = dir(data) +            ret = dict() +                 +            for field in fields: +                if field in has: +                    ret[field] = getattr(data, field) +             +            return ret + +        def _model(data, fields=()): +            """ +            Models. Will respect the `fields` and/or +            `exclude` on the handler (see `typemapper`.) +            """ +            ret = { } +            #handler = self.in_typemapper(type(data), self.anonymous)  # TRC +            handler = None                                             # TRC +            get_absolute_url = False +             +            if handler or fields: +                v = lambda f: getattr(data, f.attname) + +                if not fields: +                    """ +                    Fields was not specified, try to find teh correct +                    version in the typemapper we were sent. +                    """ +                    mapped = self.in_typemapper(type(data), self.anonymous) +                    get_fields = set(mapped.fields) +                    exclude_fields = set(mapped.exclude).difference(get_fields) +                 +                    if not get_fields: +                        get_fields = set([ f.attname.replace("_id", "", 1) +                            for f in data._meta.fields ]) +                 +                    # sets can be negated. +                    for exclude in exclude_fields: +                        if isinstance(exclude, basestring): +                            get_fields.discard(exclude) +                             +                        elif isinstance(exclude, re._pattern_type): +                            for field in get_fields.copy(): +                                if exclude.match(field): +                                    get_fields.discard(field) +                     +                    get_absolute_url = True + +                else: +                    get_fields = set(fields) +                    if 'absolute_url' in get_fields:   # MOVED (TRC) +                        get_absolute_url = True + +                met_fields = _method_fields(handler, get_fields)  # TRC + +                for f in data._meta.local_fields: +                    if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]): +                        if not f.rel: +                            if f.attname in get_fields: +                                ret[f.attname] = _any(v(f)) +                                get_fields.remove(f.attname) +                        else: +                            if f.attname[:-3] in get_fields: +                                ret[f.name] = _fk(data, f) +                                get_fields.remove(f.name) +                 +                for mf in data._meta.many_to_many: +                    if mf.serialize and mf.attname not in met_fields: +                        if mf.attname in get_fields: +                            ret[mf.name] = _m2m(data, mf) +                            get_fields.remove(mf.name) +                 +                # try to get the remainder of fields +                for maybe_field in get_fields: +                     +                    if isinstance(maybe_field, (list, tuple)): +                        model, fields = maybe_field +                        inst = getattr(data, model, None) + +                        if inst: +                            if hasattr(inst, 'all'): +                                ret[model] = _related(inst, fields) +                            elif callable(inst): +                                if len(inspect.getargspec(inst)[0]) == 1: +                                    ret[model] = _any(inst(), fields) +                            else: +                                ret[model] = _model(inst, fields) + +                    elif maybe_field in met_fields: +                        # Overriding normal field which has a "resource method" +                        # so you can alter the contents of certain fields without +                        # using different names. +                        ret[maybe_field] = _any(met_fields[maybe_field](data)) + +                    else:                     +                        maybe = getattr(data, maybe_field, None) +                        if maybe: +                            if callable(maybe): +                                if len(inspect.getargspec(maybe)[0]) == 1: +                                    ret[maybe_field] = _any(maybe()) +                            else: +                                ret[maybe_field] = _any(maybe) +                        else: +                            pass   # TRC +                            #handler_f = getattr(handler or self.handler, maybe_field, None) +                            # +                            #if handler_f: +                            #    ret[maybe_field] = _any(handler_f(data)) + +            else: +                # Add absolute_url if it exists +                get_absolute_url = True +                 +                # Add all the fields +                for f in data._meta.fields: +                    if f.attname != 'id': +                        ret[f.attname] = _any(getattr(data, f.attname)) +                 +                # Add all the propertiess +                klass = data.__class__ +                for attr in dir(klass): +                    if not attr.startswith('_') and not attr in ('pk','id') and isinstance(getattr(klass, attr, None), property): +                        #if attr.endswith('_url') or attr.endswith('_uri'): +                        #    ret[attr] = self.make_absolute(_any(getattr(data, attr))) +                        #else: +                        ret[attr] = _any(getattr(data, attr)) +                #fields = dir(data.__class__) + ret.keys() +                #add_ons = [k for k in dir(data) if k not in fields and not k.startswith('_')] +                #print add_ons +                ###print dir(data.__class__) +                #from django.db.models import Model +                #model_fields = dir(Model) + +                #for attr in dir(data): +                ##    #if attr.startswith('_'): +                ##    #    continue +                #    if (attr in fields) and not (attr in model_fields) and not attr.startswith('_'): +                #        print attr, type(getattr(data, attr, None)), attr in fields, attr in model_fields +                 +                #for k in add_ons: +                #    ret[k] = _any(getattr(data, k)) +             +            # TRC +            # resouce uri +            #if self.in_typemapper(type(data), self.anonymous): +            #    handler = self.in_typemapper(type(data), self.anonymous) +            #    if hasattr(handler, 'resource_uri'): +            #        url_id, fields = handler.resource_uri() +            #        ret['resource_uri'] = permalink( lambda: (url_id,  +            #            (getattr(data, f) for f in fields) ) )() +             +            # TRC +            #if hasattr(data, 'get_api_url') and 'resource_uri' not in ret: +            #    try: ret['resource_uri'] = data.get_api_url() +            #    except: pass +             +            # absolute uri +            if hasattr(data, 'get_absolute_url') and get_absolute_url: +                try: ret['absolute_url'] = data.get_absolute_url() +                except: pass +             +            for key, val in ret.items(): +                if key.endswith('_url') or key.endswith('_uri'): +                    ret[key] = self.add_domain(val) + +            return ret +         +        def _qs(data, fields=()): +            """ +            Querysets. +            """ +            return [ _any(v, fields) for v in data ] +                 +        def _list(data): +            """ +            Lists. +            """ +            return [ _any(v) for v in data ] +             +        def _dict(data): +            """ +            Dictionaries. +            """ +            return dict([ (k, _any(v)) for k, v in data.iteritems() ]) +             +        # Kickstart the seralizin'. +        return _any(data, self.fields) + + +    def post(self, request, auth, content, *args, **kwargs): +        # TODO: test creation on a non-existing resource url +        all_kw_args = dict(content.items() + kwargs.items()) +        if args: +            instance = self.model(pk=args[-1], **all_kw_args) +        else: +            instance = self.model(**all_kw_args) +        instance.save() +        headers = {} +        if hasattr(instance, 'get_absolute_url'): +            headers['Location'] = self.add_domain(instance.get_absolute_url()) +        return Response(status.HTTP_201_CREATED, instance, headers) + +    def get(self, request, auth, *args, **kwargs): +        try: +            if args: +                # If we have any none kwargs then assume the last represents the primrary key +                instance = self.model.objects.get(pk=args[-1], **kwargs) +            else: +                # Otherwise assume the kwargs uniquely identify the model +                instance = self.model.objects.get(**kwargs) +        except self.model.DoesNotExist: +            raise ResponseException(status.HTTP_404_NOT_FOUND) + +        return instance + +    def put(self, request, auth, content, *args, **kwargs): +        # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url  +        try: +            if args: +                # If we have any none kwargs then assume the last represents the primrary key +                instance = self.model.objects.get(pk=args[-1], **kwargs) +            else: +                # Otherwise assume the kwargs uniquely identify the model +                instance = self.model.objects.get(**kwargs) +            for (key, val) in content.items(): +                setattr(instance, key, val) +        except self.model.DoesNotExist: +            instance = self.model(**content) +            instance.save() + +        instance.save() +        return instance + +    def delete(self, request, auth, *args, **kwargs): +        try: +            if args: +                # If we have any none kwargs then assume the last represents the primrary key +                instance = self.model.objects.get(pk=args[-1], **kwargs) +            else: +                # Otherwise assume the kwargs uniquely identify the model +                instance = self.model.objects.get(**kwargs) +        except self.model.DoesNotExist: +            raise ResponseException(status.HTTP_404_NOT_FOUND, None, {}) + +        instance.delete() +        return +         + +class RootModelResource(ModelResource): +    """A Resource which provides default operations for list and create.""" +    allowed_methods = ('GET', 'POST') +    queryset = None + +    def get(self, request, auth, *args, **kwargs): +        queryset = self.queryset if self.queryset else self.model.objects.all() +        return queryset + + +class QueryModelResource(ModelResource): +    """Resource with default operations for list. +    TODO: provide filter/order/num_results/paging, and a create operation to create queries.""" +    allowed_methods = ('GET',) +    queryset = None + +    def get_form(self, data=None): +        return None + +    def get(self, request, auth, *args, **kwargs): +        queryset = self.queryset if self.queryset else self.model.objects.all() +        return queryset + diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py new file mode 100644 index 00000000..a656e2eb --- /dev/null +++ b/djangorestframework/parsers.py @@ -0,0 +1,89 @@ +from djangorestframework.response import status, ResponseException + +try: +    import json +except ImportError: +    import simplejson as json + +# TODO: Make all parsers only list a single media_type, rather than a list + +class BaseParser(object): +    """All parsers should extend BaseParser, specifing a media_type attribute, +    and overriding the parse() method.""" + +    media_type = None + +    def __init__(self, resource): +        """Initialise the parser with the Resource instance as state, +        in case the parser needs to access any metadata on the Resource object.""" +        self.resource = resource +     +    def parse(self, input): +        """Given some serialized input, return the deserialized output. +        The input will be the raw request content body.  The return value may be of +        any type, but for many parsers/inputs it might typically be a dict.""" +        return input + + +class JSONParser(BaseParser): +    media_type = 'application/json' + +    def parse(self, input): +        try: +            return json.loads(input) +        except ValueError, exc: +            raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)}) + + +class XMLParser(BaseParser): +    media_type = 'application/xml' + + +class FormParser(BaseParser): +    """The default parser for form data. +    Return a dict containing a single value for each non-reserved parameter. +    """ +     +    media_type = 'application/x-www-form-urlencoded' + +    def parse(self, input): +        # The FormParser doesn't parse the input as other parsers would, since Django's already done the +        # form parsing for us.  We build the content object from the request directly. +        request = self.resource.request + +        if request.method == 'PUT': +            # Fix from piston to force Django to give PUT requests the same +            # form processing that POST requests get... +            # +            # Bug fix: if _load_post_and_files has already been called, for +            # example by middleware accessing request.POST, the below code to +            # pretend the request is a POST instead of a PUT will be too late +            # to make a difference. Also calling _load_post_and_files will result  +            # in the following exception: +            #   AttributeError: You cannot set the upload handlers after the upload has been processed. +            # The fix is to check for the presence of the _post field which is set  +            # the first time _load_post_and_files is called (both by wsgi.py and  +            # modpython.py). If it's set, the request has to be 'reset' to redo +            # the query value parsing in POST mode. +            if hasattr(request, '_post'): +                del request._post +                del request._files +             +            try: +                request.method = "POST" +                request._load_post_and_files() +                request.method = "PUT" +            except AttributeError: +                request.META['REQUEST_METHOD'] = 'POST' +                request._load_post_and_files() +                request.META['REQUEST_METHOD'] = 'PUT' + +        # Strip any parameters that we are treating as reserved +        data = {} +        for (key, val) in request.POST.items(): +            if key not in self.resource.RESERVED_FORM_PARAMS: +                data[key] = val +         +        return data + + diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py new file mode 100644 index 00000000..d06d51b0 --- /dev/null +++ b/djangorestframework/resource.py @@ -0,0 +1,451 @@ +from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse +from django.http import HttpResponse + +from djangorestframework import emitters, parsers, authenticators +from djangorestframework.response import status, Response, ResponseException + +from decimal import Decimal +import re + +# TODO: Figure how out references and named urls need to work nicely +# TODO: POST on existing 404 URL, PUT on existing 404 URL +# +# NEXT: Exceptions on func() -> 500, tracebacks emitted if settings.DEBUG +# + +__all__ = ['Resource'] + + + +class Resource(object): +    """Handles incoming requests and maps them to REST operations, +    performing authentication, input deserialization, input validation, output serialization.""" + +    # List of RESTful operations which may be performed on this resource. +    allowed_methods = ('GET',) +    anon_allowed_methods = () + +    # List of emitters the resource can serialize the response with, ordered by preference +    emitters = ( emitters.JSONEmitter, +                 emitters.DocumentingHTMLEmitter, +                 emitters.DocumentingXHTMLEmitter, +                 emitters.DocumentingPlainTextEmitter, +                 emitters.XMLEmitter ) + +    # List of content-types the resource can read from +    parsers = ( parsers.JSONParser, +                parsers.XMLParser, +                parsers.FormParser ) +     +    # List of all authenticating methods to attempt +    authenticators = ( authenticators.UserLoggedInAuthenticator, +                       authenticators.BasicAuthenticator ) + +    # Optional form for input validation and presentation of HTML formatted responses. +    form = None + +    # Map standard HTTP methods to function calls +    callmap = { 'GET': 'get', 'POST': 'post',  +                'PUT': 'put', 'DELETE': 'delete' } + +    # Some reserved parameters to allow us to use standard HTML forms with our resource +    # Override any/all of these with None to disable them, or override them with another value to rename them. +    ACCEPT_QUERY_PARAM = '_accept'        # Allow override of Accept header in URL query params +    METHOD_PARAM = '_method'              # Allow POST overloading in form params +    CONTENTTYPE_PARAM = '_contenttype'    # Allow override of Content-Type header in form params (allows sending arbitrary content with standard forms) +    CONTENT_PARAM = '_content'            # Allow override of body content in form params (allows sending arbitrary content with standard forms)  +    CSRF_PARAM = 'csrfmiddlewaretoken'    # Django's CSRF token used in form params + + +    def __new__(cls, *args, **kwargs): +        """Make the class callable so it can be used as a Django view.""" +        self = object.__new__(cls) +        if args: +            request = args[0] +            self.__init__(request) +            return self._handle_request(request, *args[1:], **kwargs) +        else: +            self.__init__() +            return self + + +    def __init__(self, request=None): +        """""" +        # Setup the resource context +        self.request = request +        self.response = None +        self.form_instance = None + +        # These sets are determined now so that overridding classes can modify the various parameter names, +        # or set them to None to disable them.  +        self.RESERVED_FORM_PARAMS = set((self.METHOD_PARAM, self.CONTENTTYPE_PARAM, self.CONTENT_PARAM, self.CSRF_PARAM)) +        self.RESERVED_QUERY_PARAMS = set((self.ACCEPT_QUERY_PARAM)) +        self.RESERVED_FORM_PARAMS.discard(None) +        self.RESERVED_QUERY_PARAMS.discard(None) + + +    @property +    def name(self): +        """Provide a name for the resource. +        By default this is the class name, with 'CamelCaseNames' converted to 'Camel Case Names'.""" +        class_name = self.__class__.__name__ +        return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', class_name).strip() + +    @property +    def description(self): +        """Provide a description for the resource. +        By default this is the class's docstring with leading line spaces stripped.""" +        return re.sub(re.compile('^ +', re.MULTILINE), '', self.__doc__) + +    @property +    def emitted_media_types(self): +        """Return an list of all the media types that this resource can emit.""" +        return [emitter.media_type for emitter in self.emitters] + +    @property +    def default_emitter(self): +        """Return the resource's most prefered emitter. +        (This emitter is used if the client does not send and Accept: header, or sends Accept: */*)""" +        return self.emitters[0] + +    @property +    def parsed_media_types(self): +        """Return an list of all the media types that this resource can emit.""" +        return [parser.media_type for parser in self.parsers] +     +    @property +    def default_parser(self): +        """Return the resource's most prefered emitter. +        (This has no behavioural effect, but is may be used by documenting emitters)"""         +        return self.parsers[0] + + +    def get(self, request, auth, *args, **kwargs): +        """Must be subclassed to be implemented.""" +        self.not_implemented('GET') + + +    def post(self, request, auth, content, *args, **kwargs): +        """Must be subclassed to be implemented.""" +        self.not_implemented('POST') + + +    def put(self, request, auth, content, *args, **kwargs): +        """Must be subclassed to be implemented.""" +        self.not_implemented('PUT') + + +    def delete(self, request, auth, *args, **kwargs): +        """Must be subclassed to be implemented.""" +        self.not_implemented('DELETE') + + +    def reverse(self, view, *args, **kwargs): +        """Return a fully qualified URI for a given view or resource. +        Add the domain using the Sites framework if possible, otherwise fallback to using the current request.""" +        return self.add_domain(reverse(view, args=args, kwargs=kwargs)) + + +    def not_implemented(self, operation): +        """Return an HTTP 500 server error if an operation is called which has been allowed by +        allowed_methods, but which has not been implemented.""" +        raise ResponseException(status.HTTP_500_INTERNAL_SERVER_ERROR, +                                {'detail': '%s operation on this resource has not been implemented' % (operation, )}) + + +    def add_domain(self, path): +        """Given a path, return an fully qualified URI. +        Use the Sites framework if possible, otherwise fallback to using the domain from the current request.""" + +        # Note that out-of-the-box the Sites framework uses the reserved domain 'example.com' +        # See RFC 2606 - http://www.faqs.org/rfcs/rfc2606.html +        try: +            site = Site.objects.get_current() +            if site.domain and site.domain != 'example.com': +                return 'http://%s%s' % (site.domain, path) +        except: +            pass + +        return self.request.build_absolute_uri(path) +     +     +    def determine_method(self, request): +        """Determine the HTTP method that this request should be treated as. +        Allows PUT and DELETE tunneling via the _method parameter if METHOD_PARAM is set.""" +        method = request.method.upper() + +        if method == 'POST' and self.METHOD_PARAM and request.POST.has_key(self.METHOD_PARAM): +            method = request.POST[self.METHOD_PARAM].upper() +         +        return method + + +    def authenticate(self, request): +        """Attempt to authenticate the request, returning an authentication context or None. +        An authentication context may be any object, although in many cases it will be a User instance.""" +         +        # Attempt authentication against each authenticator in turn, +        # and return None if no authenticators succeed in authenticating the request. +        for authenticator in self.authenticators: +            auth_context = authenticator(self).authenticate(request) +            if auth_context: +                return auth_context + +        return None + + +    def check_method_allowed(self, method, auth): +        """Ensure the request method is permitted for this resource, raising a ResourceException if it is not.""" + +        if not method in self.callmap.keys(): +            raise ResponseException(status.HTTP_501_NOT_IMPLEMENTED, +                                    {'detail': 'Unknown or unsupported method \'%s\'' % method}) + +        if not method in self.allowed_methods: +            raise ResponseException(status.HTTP_405_METHOD_NOT_ALLOWED, +                                    {'detail': 'Method \'%s\' not allowed on this resource.' % method}) + +        if auth is None and not method in self.anon_allowed_methods: +            raise ResponseException(status.HTTP_403_FORBIDDEN, +                                    {'detail': 'You do not have permission to access this resource. ' + +                                     'You may need to login or otherwise authenticate the request.'}) + +    def get_form(self, data=None): +        """Optionally return a Django Form instance, which may be used for validation +        and/or rendered by an HTML/XHTML emitter. +         +        If data is not None the form will be bound to data.""" + +        if self.form: +            if data: +                return self.form(data) +            else: +                return self.form() +        return None +   +   +    def cleanup_request(self, data, form_instance): +        """Perform any resource-specific data deserialization and/or validation +        after the initial HTTP content-type deserialization has taken place. +         +        Returns a tuple containing the cleaned up data, and optionally a form bound to that data. +         +        By default this uses form validation to filter the basic input into the required types.""" + +        if form_instance is None: +            return data +         +        # Default form validation does not check for additional invalid fields +        non_existent_fields = [] +        for key in set(data.keys()) - set(form_instance.fields.keys()): +            non_existent_fields.append(key) + +        if not form_instance.is_valid() or non_existent_fields: +            if not form_instance.errors and not non_existent_fields: +                # If no data was supplied the errors property will be None +                details = 'No content was supplied' +                 +            else: +                # Add standard field errors +                details = dict((key, map(unicode, val)) for (key, val) in form_instance.errors.iteritems() if key != '__all__') + +                # Add any non-field errors +                if form_instance.non_field_errors(): +                    details['errors'] = form_instance.non_field_errors() + +                # Add any non-existent field errors +                for key in non_existent_fields: +                    details[key] = ['This field does not exist'] + +            # Bail.  Note that we will still serialize this response with the appropriate content type  +            raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': details}) + +        return form_instance.cleaned_data + + +    def cleanup_response(self, data): +        """Perform any resource-specific data filtering prior to the standard HTTP +        content-type serialization. + +        Eg filter complex objects that cannot be serialized by json/xml/etc into basic objects that can.""" +        return data + + +    def determine_parser(self, request): +        """Return the appropriate parser for the input, given the client's 'Content-Type' header, +        and the content types that this Resource knows how to parse.""" +        content_type = request.META.get('CONTENT_TYPE', 'application/x-www-form-urlencoded') +        raw_content = request.raw_post_data +     +        split = content_type.split(';', 1) +        if len(split) > 1: +            content_type = split[0] +        content_type = content_type.strip() +         +        # If CONTENTTYPE_PARAM is turned on, and this is a standard POST form then allow the content type to be overridden +        if (content_type == 'application/x-www-form-urlencoded' and +            request.method == 'POST' and +            self.CONTENTTYPE_PARAM and +            self.CONTENT_PARAM and +            request.POST.get(self.CONTENTTYPE_PARAM, None) and +            request.POST.get(self.CONTENT_PARAM, None)): +            raw_content = request.POST[self.CONTENT_PARAM] +            content_type = request.POST[self.CONTENTTYPE_PARAM] + +        # Create a list of list of (media_type, Parser) tuples +        media_type_to_parser = dict([(parser.media_type, parser) for parser in self.parsers]) + +        try: +            return (media_type_to_parser[content_type], raw_content) +        except KeyError: +            raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, +                                    {'detail': 'Unsupported media type \'%s\'' % content_type}) + + +    def determine_emitter(self, request): +        """Return the appropriate emitter for the output, given the client's 'Accept' header, +        and the content types that this Resource knows how to serve. +         +        See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html""" + +        if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None): +            # Use _accept parameter override +            accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)] +        elif request.META.has_key('HTTP_ACCEPT'): +            # Use standard HTTP Accept negotiation +            accept_list = request.META["HTTP_ACCEPT"].split(',') +        else: +            # No accept header specified +            return self.default_emitter +         +        # Parse the accept header into a dict of {qvalue: set of media types} +        # We ignore mietype parameters +        accept_dict = {}     +        for token in accept_list: +            components = token.split(';') +            mimetype = components[0].strip() +            qvalue = Decimal('1.0') +             +            if len(components) > 1: +                # Parse items that have a qvalue eg text/html;q=0.9 +                try: +                    (q, num) = components[-1].split('=') +                    if q == 'q': +                        qvalue = Decimal(num) +                except: +                    # Skip malformed entries +                    continue + +            if accept_dict.has_key(qvalue): +                accept_dict[qvalue].add(mimetype) +            else: +                accept_dict[qvalue] = set((mimetype,)) +         +        # Convert to a list of sets ordered by qvalue (highest first) +        accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)] +        +        for accept_set in accept_sets: +            # Return any exact match +            for emitter in self.emitters: +                if emitter.media_type in accept_set: +                    return emitter + +            # Return any subtype match +            for emitter in self.emitters: +                if emitter.media_type.split('/')[0] + '/*' in accept_set: +                    return emitter + +            # Return default +            if '*/*' in accept_set: +                return self.default_emitter +       + +        raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE, +                                {'detail': 'Could not statisfy the client\'s Accept header', +                                 'available_types': self.emitted_media_types}) + + +    def _handle_request(self, request, *args, **kwargs): +        """This method is the core of Resource, through which all requests are passed. + +        Broadly this consists of the following procedure: + +        0. ensure the operation is permitted +        1. deserialize request content into request data, using standard HTTP content types (PUT/POST only) +        2. cleanup and validate request data (PUT/POST only) +        3. call the core method to get the response data +        4. cleanup the response data +        5. serialize response data into response content, using standard HTTP content negotiation +        """ +        emitter = None +        method = self.determine_method(request) + +        try: +            # Before we attempt anything else determine what format to emit our response data with. +            emitter = self.determine_emitter(request) + +            # Authenticate the request, and store any context so that the resource operations can +            # do more fine grained authentication if required. +            # +            # Typically the context will be a user, or None if this is an anonymous request, +            # but it could potentially be more complex (eg the context of a request key which +            # has been signed against a particular set of permissions) +            auth_context = self.authenticate(request) + +            # Ensure the requested operation is permitted on this resource +            self.check_method_allowed(method, auth_context) + +            # Get the appropriate create/read/update/delete function +            func = getattr(self, self.callmap.get(method, None)) +     +            # Either generate the response data, deserializing and validating any request data +            # TODO: Add support for message bodys on other HTTP methods, as it is valid. +            if method in ('PUT', 'POST'): +                (parser, raw_content) = self.determine_parser(request) +                data = parser(self).parse(raw_content) +                self.form_instance = self.get_form(data) +                data = self.cleanup_request(data, self.form_instance) +                response = func(request, auth_context, data, *args, **kwargs) + +            else: +                response = func(request, auth_context, *args, **kwargs) + +            # Allow return value to be either Response, or an object, or None +            if isinstance(response, Response): +                self.response = response +            elif response is not None: +                self.response = Response(status.HTTP_200_OK, response) +            else: +                self.response = Response(status.HTTP_204_NO_CONTENT) + +            # Pre-serialize filtering (eg filter complex objects into natively serializable types) +            self.response.cleaned_content = self.cleanup_response(self.response.raw_content) + + +        except ResponseException, exc: +            self.response = exc.response + +            # Fall back to the default emitter if we failed to perform content negotiation +            if emitter is None: +                emitter = self.default_emitter + + +        # Always add these headers +        self.response.headers['Allow'] = ', '.join(self.allowed_methods) +        self.response.headers['Vary'] = 'Authenticate, Allow' + +        # Serialize the response content +        if self.response.has_content_body: +            content = emitter(self).emit(output=self.response.cleaned_content) +        else: +            content = emitter(self).emit() + +        # Build the HTTP Response +        # TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set +        resp = HttpResponse(content, mimetype=emitter.media_type, status=self.response.status) +        for (key, val) in self.response.headers.items(): +            resp[key] = val + +        return resp + diff --git a/djangorestframework/response.py b/djangorestframework/response.py new file mode 100644 index 00000000..4f23bb0a --- /dev/null +++ b/djangorestframework/response.py @@ -0,0 +1,125 @@ +from django.core.handlers.wsgi import STATUS_CODE_TEXT + +__all__ =['status', 'NoContent', 'Response', ] + + +class Status(object): +    """Descriptive HTTP status codes, for code readability. +    See RFC 2616 - Sec 10: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html""" +     +    # Verbose format (I prefer this as it's more explicit) +    HTTP_100_CONTINUE = 100 +    HTTP_101_SWITCHING_PROTOCOLS = 101 +    HTTP_200_OK = 200 +    HTTP_201_CREATED = 201 +    HTTP_202_ACCEPTED = 202 +    HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203 +    HTTP_204_NO_CONTENT = 204 +    HTTP_205_RESET_CONTENT = 205 +    HTTP_206_PARTIAL_CONTENT = 206 +    HTTP_300_MULTIPLE_CHOICES = 300 +    HTTP_301_MOVED_PERMANENTLY = 301 +    HTTP_302_FOUND = 302 +    HTTP_303_SEE_OTHER = 303 +    HTTP_304_NOT_MODIFIED = 304 +    HTTP_305_USE_PROXY = 305 +    HTTP_306_RESERVED = 306 +    HTTP_307_TEMPORARY_REDIRECT = 307 +    HTTP_400_BAD_REQUEST = 400 +    HTTP_401_UNAUTHORIZED = 401 +    HTTP_402_PAYMENT_REQUIRED = 402 +    HTTP_403_FORBIDDEN = 403 +    HTTP_404_NOT_FOUND = 404 +    HTTP_405_METHOD_NOT_ALLOWED = 405 +    HTTP_406_NOT_ACCEPTABLE = 406 +    HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407 +    HTTP_408_REQUEST_TIMEOUT = 408 +    HTTP_409_CONFLICT = 409 +    HTTP_410_GONE = 410 +    HTTP_411_LENGTH_REQUIRED = 411 +    HTTP_412_PRECONDITION_FAILED = 412 +    HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413 +    HTTP_414_REQUEST_URI_TOO_LONG = 414 +    HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 +    HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 +    HTTP_417_EXPECTATION_FAILED = 417 +    HTTP_500_INTERNAL_SERVER_ERROR = 500 +    HTTP_501_NOT_IMPLEMENTED = 501 +    HTTP_502_BAD_GATEWAY = 502 +    HTTP_503_SERVICE_UNAVAILABLE = 503 +    HTTP_504_GATEWAY_TIMEOUT = 504 +    HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505 + +    # Short format +    CONTINUE = 100 +    SWITCHING_PROTOCOLS = 101 +    OK = 200 +    CREATED = 201 +    ACCEPTED = 202 +    NON_AUTHORITATIVE_INFORMATION = 203 +    NO_CONTENT = 204 +    RESET_CONTENT = 205 +    PARTIAL_CONTENT = 206 +    MULTIPLE_CHOICES = 300 +    MOVED_PERMANENTLY = 301 +    FOUND = 302 +    SEE_OTHER = 303 +    NOT_MODIFIED = 304 +    USE_PROXY = 305 +    RESERVED = 306 +    TEMPORARY_REDIRECT = 307 +    BAD_REQUEST = 400 +    UNAUTHORIZED = 401 +    PAYMENT_REQUIRED = 402 +    FORBIDDEN = 403 +    NOT_FOUND = 404 +    METHOD_NOT_ALLOWED = 405 +    NOT_ACCEPTABLE = 406 +    PROXY_AUTHENTICATION_REQUIRED = 407 +    REQUEST_TIMEOUT = 408 +    CONFLICT = 409 +    GONE = 410 +    LENGTH_REQUIRED = 411 +    PRECONDITION_FAILED = 412 +    REQUEST_ENTITY_TOO_LARGE = 413 +    REQUEST_URI_TOO_LONG = 414 +    UNSUPPORTED_MEDIA_TYPE = 415 +    REQUESTED_RANGE_NOT_SATISFIABLE = 416 +    EXPECTATION_FAILED = 417 +    INTERNAL_SERVER_ERROR = 500 +    NOT_IMPLEMENTED = 501 +    BAD_GATEWAY = 502 +    SERVICE_UNAVAILABLE = 503 +    GATEWAY_TIMEOUT = 504 +    HTTP_VERSION_NOT_SUPPORTED = 505 + + + +# This is simply stylistic, I think 'status.HTTP_200_OK' reads nicely. +status = Status() + + +class NoContent(object): +    """Used to indicate no body in http response. +    (We cannot just use None, as that is a valid, serializable response object.)""" +    pass + + +class Response(object): +    def __init__(self, status, content=NoContent, headers={}): +        self.status = status +        self.has_content_body = not content is NoContent +        self.raw_content = content      # content prior to filtering +        self.cleaned_content = content  # content after filtering +        self.headers = headers +  +    @property +    def status_text(self): +        """Return reason text corrosponding to our HTTP response status code. +        Provided for convienience.""" +        return STATUS_CODE_TEXT.get(self.status, '') + + +class ResponseException(BaseException): +    def __init__(self, status, content=NoContent, headers={}): +        self.response = Response(status, content=content, headers=headers) diff --git a/djangorestframework/templates/emitter.html b/djangorestframework/templates/emitter.html new file mode 100644 index 00000000..d21350cd --- /dev/null +++ b/djangorestframework/templates/emitter.html @@ -0,0 +1,108 @@ +{% load urlize_quoted_links %}{% load add_query_param %}<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"  +        "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> +  <head> +    <style> +      pre {border: 1px solid black; padding: 1em; background: #ffd} +      body {margin: 0; border:0; padding: 0;} +      span.api {margin: 0.5em 1em} +      span.auth {float: right; margin-right: 1em} +      div.header {margin: 0; border:0; padding: 0.25em 0; background: #ddf} +      div.content {margin: 0 1em;} +      div.action {border: 1px solid black; padding: 0.5em 1em; margin-bottom: 0.5em; background: #ddf} +      ul.accepttypes {float: right; list-style-type: none; margin: 0; padding: 0} +      ul.accepttypes li {display: inline;} +      form div {margin: 0.5em 0} +	  form div * {vertical-align: top} +	  form ul.errorlist {display: inline; margin: 0; padding: 0} +	  form ul.errorlist li {display: inline; color: red;} +	  .clearing {display: block; margin: 0; padding: 0; clear: both;} +    </style> +    <title>API - {{ resource.name }}</title> +  </head> +  <body> +	<div class='header'> +		<span class='api'><a href='http://django-rest-framework.org'>Django REST framework</a></span> +		<span class='auth'>{% if user.is_active %}Welcome, {{ user }}.{% if logout_url %} <a href='{{ logout_url }}'>Log out</a>{% endif %}{% else %}Not logged in {% if login_url %}<a href='{{ login_url }}'>Log in</a>{% endif %}{% endif %}</span> +	</div> +	<div class='content'> +	    <h1>{{ resource.name }}</h1> +	    <p>{{ resource.description|linebreaksbr }}</p> +	    <pre><b>{{ response.status }} {{ response.status_text }}</b>{% autoescape off %} +{% for key, val in response.headers.items %}<b>{{ key }}:</b> {{ val|urlize_quoted_links }} +{% endfor %} +{{ content|urlize_quoted_links }}</pre>{% endautoescape %} +	 +	{% if 'GET' in resource.allowed_methods %} +		<div class='action'> +			<a href='{{ request.path }}' rel="nofollow">GET</a> +			<ul class="accepttypes"> +			{% for media_type in resource.emitted_media_types %} +			  {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} +			    <li>[<a href='{{ request.path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>]</li> +			  {% endwith %} +			{% endfor %} +			</ul> +			<div class="clearing"></div> +		</div> +	{% 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 %} +			<div class='action'> +				<form action="{{ request.path }}" method="post"> +				    {% csrf_token %} +				    {{ form.non_field_errors }} +					{% for field in form %} +					<div> +					    {{ field.label_tag }}: +					    {{ field }} +					    {{ field.help_text }} +					    {{ field.errors }} +					</div> +					{% endfor %} +					<div class="clearing"></div>	 +					<input type="submit" value="POST" /> +				</form> +			</div> +		{% endif %} +		 +		{% if 'PUT' in resource.allowed_methods %} +			<div class='action'> +				<form action="{{ request.path }}" method="post"> +					<input type="hidden" name="{{ resource.METHOD_PARAM }}" value="PUT" /> +					{% csrf_token %} +					{{ form.non_field_errors }} +					{% for field in form %} +					<div> +					    {{ field.label_tag }}: +					    {{ field }} +					    {{ field.help_text }} +					    {{ field.errors }}			     +					</div> +					{% endfor %} +					<div class="clearing"></div>	 +					<input type="submit" value="PUT" /> +				</form> +			</div> +		{% endif %} +		 +		{% if 'DELETE' in resource.allowed_methods %} +			<div class='action'> +				<form action="{{ request.path }}" method="post"> +				    {% csrf_token %} +					<input type="hidden" name="{{ resource.METHOD_PARAM }}" value="DELETE" /> +					<input type="submit" value="DELETE" /> +				</form> +			</div> +		{% endif %} +	{% endif %} +	</div> +  </body> +</html>
\ No newline at end of file diff --git a/djangorestframework/templates/emitter.txt b/djangorestframework/templates/emitter.txt new file mode 100644 index 00000000..1cc7d1d7 --- /dev/null +++ b/djangorestframework/templates/emitter.txt @@ -0,0 +1,8 @@ +{{ resource.name }} + +{{ resource.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/templatetags/__init__.py b/djangorestframework/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/djangorestframework/templatetags/__init__.py diff --git a/djangorestframework/templatetags/add_query_param.py b/djangorestframework/templatetags/add_query_param.py new file mode 100644 index 00000000..91c1a312 --- /dev/null +++ b/djangorestframework/templatetags/add_query_param.py @@ -0,0 +1,17 @@ +from django.template import Library +from urlparse import urlparse, urlunparse +from urllib import quote +register = Library() + +def add_query_param(url, param): +    (key, val) = param.split('=') +    param = '%s=%s' % (key, quote(val)) +    (scheme, netloc, path, params, query, fragment) = urlparse(url) +    if query: +        query += "&" + param +    else: +        query = param +    return urlunparse((scheme, netloc, path, params, query, fragment)) + + +register.filter('add_query_param', add_query_param) diff --git a/djangorestframework/templatetags/urlize_quoted_links.py b/djangorestframework/templatetags/urlize_quoted_links.py new file mode 100644 index 00000000..eea424a4 --- /dev/null +++ b/djangorestframework/templatetags/urlize_quoted_links.py @@ -0,0 +1,100 @@ +"""Adds the custom filter 'urlize_quoted_links' + +This is identical to the built-in filter 'urlize' with the exception that  +single and double quotes are permitted as leading or trailing punctuation. +""" + +# Almost all of this code is copied verbatim from django.utils.html +# LEADING_PUNCTUATION and TRAILING_PUNCTUATION have been modified +import re +import string + +from django.utils.safestring import SafeData, mark_safe +from django.utils.encoding import force_unicode +from django.utils.http import urlquote +from django.utils.html import escape +from django import template + +# Configuration for urlize() function. +LEADING_PUNCTUATION  = ['(', '<', '<', '"', "'"] +TRAILING_PUNCTUATION = ['.', ',', ')', '>', '\n', '>', '"', "'"] + +# List of possible strings used for bullets in bulleted lists. +DOTS = ['·', '*', '\xe2\x80\xa2', '•', '•', '•'] + +unencoded_ampersands_re = re.compile(r'&(?!(\w+|#\d+);)') +word_split_re = re.compile(r'(\s+)') +punctuation_re = re.compile('^(?P<lead>(?:%s)*)(?P<middle>.*?)(?P<trail>(?:%s)*)$' % \ +    ('|'.join([re.escape(x) for x in LEADING_PUNCTUATION]), +    '|'.join([re.escape(x) for x in TRAILING_PUNCTUATION]))) +simple_email_re = re.compile(r'^\S+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+$') +link_target_attribute_re = re.compile(r'(<a [^>]*?)target=[^\s>]+') +html_gunk_re = re.compile(r'(?:<br clear="all">|<i><\/i>|<b><\/b>|<em><\/em>|<strong><\/strong>|<\/?smallcaps>|<\/?uppercase>)', re.IGNORECASE) +hard_coded_bullets_re = re.compile(r'((?:<p>(?:%s).*?[a-zA-Z].*?</p>\s*)+)' % '|'.join([re.escape(x) for x in DOTS]), re.DOTALL) +trailing_empty_content_re = re.compile(r'(?:<p>(?: |\s|<br \/>)*?</p>\s*)+\Z') + +def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=True): +    """ +    Converts any URLs in text into clickable links. + +    Works on http://, https://, www. links and links ending in .org, .net or +    .com. Links can have trailing punctuation (periods, commas, close-parens) +    and leading punctuation (opening parens) and it'll still do the right +    thing. + +    If trim_url_limit is not None, the URLs in link text longer than this limit +    will truncated to trim_url_limit-3 characters and appended with an elipsis. + +    If nofollow is True, the URLs in link text will get a rel="nofollow" +    attribute. + +    If autoescape is True, the link text and URLs will get autoescaped. +    """ +    trim_url = lambda x, limit=trim_url_limit: limit is not None and (len(x) > limit and ('%s...' % x[:max(0, limit - 3)])) or x +    safe_input = isinstance(text, SafeData) +    words = word_split_re.split(force_unicode(text)) +    nofollow_attr = nofollow and ' rel="nofollow"' or '' +    for i, word in enumerate(words): +        match = None +        if '.' in word or '@' in word or ':' in word: +            match = punctuation_re.match(word) +        if match: +            lead, middle, trail = match.groups() +            # Make URL we want to point to. +            url = None +            if middle.startswith('http://') or middle.startswith('https://'): +                url = urlquote(middle, safe='/&=:;#?+*') +            elif middle.startswith('www.') or ('@' not in middle and \ +                    middle and middle[0] in string.ascii_letters + string.digits and \ +                    (middle.endswith('.org') or middle.endswith('.net') or middle.endswith('.com'))): +                url = urlquote('http://%s' % middle, safe='/&=:;#?+*') +            elif '@' in middle and not ':' in middle and simple_email_re.match(middle): +                url = 'mailto:%s' % middle +                nofollow_attr = '' +            # Make link. +            if url: +                trimmed = trim_url(middle) +                if autoescape and not safe_input: +                    lead, trail = escape(lead), escape(trail) +                    url, trimmed = escape(url), escape(trimmed) +                middle = '<a href="%s"%s>%s</a>' % (url, nofollow_attr, trimmed) +                words[i] = mark_safe('%s%s%s' % (lead, middle, trail)) +            else: +                if safe_input: +                    words[i] = mark_safe(word) +                elif autoescape: +                    words[i] = escape(word) +        elif safe_input: +            words[i] = mark_safe(word) +        elif autoescape: +            words[i] = escape(word) +    return u''.join(words) + + +#urlize_quoted_links.needs_autoescape = True +urlize_quoted_links.is_safe = True + +# Register urlize_quoted_links as a custom filter +# http://docs.djangoproject.com/en/dev/howto/custom-template-tags/ +register = template.Library() +register.filter(urlize_quoted_links) diff --git a/djangorestframework/utils.py b/djangorestframework/utils.py new file mode 100644 index 00000000..f9bbc0fe --- /dev/null +++ b/djangorestframework/utils.py @@ -0,0 +1,179 @@ +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 +try: +    import cStringIO as StringIO +except ImportError: +    import StringIO + + +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 piston +def coerce_put_post(request): +    """ +    Django doesn't particularly understand REST. +    In case we send data over PUT, Django won't +    actually look at the data and load it. We need +    to twist its arm here. +     +    The try/except abominiation here is due to a bug +    in mod_python. This should fix it. +    """ +    if request.method != 'PUT': +        return + +    # Bug fix: if _load_post_and_files has already been called, for +    # example by middleware accessing request.POST, the below code to +    # pretend the request is a POST instead of a PUT will be too late +    # to make a difference. Also calling _load_post_and_files will result  +    # in the following exception: +    #   AttributeError: You cannot set the upload handlers after the upload has been processed. +    # The fix is to check for the presence of the _post field which is set  +    # the first time _load_post_and_files is called (both by wsgi.py and  +    # modpython.py). If it's set, the request has to be 'reset' to redo +    # the query value parsing in POST mode. +    if hasattr(request, '_post'): +        del request._post +        del request._files +     +    try: +        request.method = "POST" +        request._load_post_and_files() +        request.method = "PUT" +    except AttributeError: +        request.META['REQUEST_METHOD'] = 'POST' +        request._load_post_and_files() +        request.META['REQUEST_METHOD'] = 'PUT' +         +    request.PUT = request.POST + +# 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 XMLEmitter(): +    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 XMLEmitter().dict2xml(input) | 
