From 42f2f9b40d1295e18a5b720b0d1f6ad85e928d8a Mon Sep 17 00:00:00 2001 From: tom christie tom@tomchristie.com Date: Sun, 30 Jan 2011 18:30:39 +0000 Subject: Rename to django-rest-framework, get simpleexample working --- README.txt | 20 +- djangorestframework/__init__.py | 0 djangorestframework/authenticators.py | 44 ++ djangorestframework/emitters.py | 216 ++++++++++ djangorestframework/modelresource.py | 420 +++++++++++++++++++ djangorestframework/parsers.py | 89 ++++ djangorestframework/resource.py | 451 +++++++++++++++++++++ djangorestframework/response.py | 125 ++++++ djangorestframework/templates/emitter.html | 108 +++++ djangorestframework/templates/emitter.txt | 8 + djangorestframework/templatetags/__init__.py | 0 .../templatetags/add_query_param.py | 17 + .../templatetags/urlize_quoted_links.py | 100 +++++ djangorestframework/utils.py | 179 ++++++++ docs/conf.py | 2 +- docs/index.rst | 28 +- docs/requirements.txt | 8 + examples/blogpost/views.py | 6 +- examples/objectstore/views.py | 4 +- examples/pygments_api/views.py | 6 +- examples/requirements.txt | 6 + examples/settings.py | 9 +- examples/simpleexample/__init__.py | 0 examples/simpleexample/models.py | 23 ++ examples/simpleexample/urls.py | 6 + examples/simpleexample/views.py | 18 + examples/urls.py | 27 +- flywheel/__init__.py | 0 flywheel/authenticators.py | 44 -- flywheel/emitters.py | 216 ---------- flywheel/modelresource.py | 402 ------------------ flywheel/parsers.py | 89 ---- flywheel/resource.py | 451 --------------------- flywheel/response.py | 125 ------ flywheel/templates/emitter.html | 108 ----- flywheel/templates/emitter.txt | 8 - flywheel/templatetags/__init__.py | 0 flywheel/templatetags/add_query_param.py | 17 - flywheel/templatetags/urlize_quoted_links.py | 100 ----- flywheel/utils.py | 179 -------- requirements.txt | 2 + 41 files changed, 1874 insertions(+), 1787 deletions(-) create mode 100644 djangorestframework/__init__.py create mode 100644 djangorestframework/authenticators.py create mode 100644 djangorestframework/emitters.py create mode 100644 djangorestframework/modelresource.py create mode 100644 djangorestframework/parsers.py create mode 100644 djangorestframework/resource.py create mode 100644 djangorestframework/response.py create mode 100644 djangorestframework/templates/emitter.html create mode 100644 djangorestframework/templates/emitter.txt create mode 100644 djangorestframework/templatetags/__init__.py create mode 100644 djangorestframework/templatetags/add_query_param.py create mode 100644 djangorestframework/templatetags/urlize_quoted_links.py create mode 100644 djangorestframework/utils.py create mode 100644 docs/requirements.txt create mode 100644 examples/requirements.txt create mode 100644 examples/simpleexample/__init__.py create mode 100644 examples/simpleexample/models.py create mode 100644 examples/simpleexample/urls.py create mode 100644 examples/simpleexample/views.py delete mode 100644 flywheel/__init__.py delete mode 100644 flywheel/authenticators.py delete mode 100644 flywheel/emitters.py delete mode 100644 flywheel/modelresource.py delete mode 100644 flywheel/parsers.py delete mode 100644 flywheel/resource.py delete mode 100644 flywheel/response.py delete mode 100644 flywheel/templates/emitter.html delete mode 100644 flywheel/templates/emitter.txt delete mode 100644 flywheel/templatetags/__init__.py delete mode 100644 flywheel/templatetags/add_query_param.py delete mode 100644 flywheel/templatetags/urlize_quoted_links.py delete mode 100644 flywheel/utils.py diff --git a/README.txt b/README.txt index 44e4f96d..784713b6 100644 --- a/README.txt +++ b/README.txt @@ -1,17 +1,21 @@ -# To install django-rest-framework... -# -# Requirements: -# python2.6 -# virtualenv +# To install django-rest-framework in a virtualenv environment... hg clone https://tomchristie@bitbucket.org/tomchristie/django-rest-framework cd django-rest-framework/ virtualenv --no-site-packages --distribute --python=python2.6 env source ./env/bin/activate -pip install -r ./requirements.txt -python ./src/manage.py test +pip install -r requirements.txt # To build the documentation... -sphinx-build -c docs -b html -d cache docs html +pip install -r docs/requirements.txt +sphinx-build -c docs -b html -d docs-build docs html + +# To run the examples... + +pip install -r examples/requirements.txt +cd examples +export PYTHONPATH=.. +python manage.py syncdb +python manage.py runserver diff --git a/djangorestframework/__init__.py b/djangorestframework/__init__.py new file mode 100644 index 00000000..e69de29b 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 %} + + + + + API - {{ resource.name }} + + +
+ Django REST framework + {% if user.is_active %}Welcome, {{ user }}.{% if logout_url %} Log out{% endif %}{% else %}Not logged in {% if login_url %}Log in{% endif %}{% endif %} +
+
+

{{ resource.name }}

+

{{ resource.description|linebreaksbr }}

+
{{ 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 + +
+
+ {% 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 %} +
+
+ {% 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 %} +
+
+ + {% 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 %} +
+
+ {% csrf_token %} + + +
+
+ {% endif %} + {% endif %} +
+ + \ 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 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(?:%s)*)(?P.*?)(?P(?:%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'(]*?)target=[^\s>]+') +html_gunk_re = re.compile(r'(?:
|<\/i>|<\/b>|<\/em>|<\/strong>|<\/?smallcaps>|<\/?uppercase>)', re.IGNORECASE) +hard_coded_bullets_re = re.compile(r'((?:

(?:%s).*?[a-zA-Z].*?

\s*)+)' % '|'.join([re.escape(x) for x in DOTS]), re.DOTALL) +trailing_empty_content_re = re.compile(r'(?:

(?: |\s|
)*?

\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 = '
%s' % (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) diff --git a/docs/conf.py b/docs/conf.py index 71cacb3d..3689a636 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,7 @@ import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) -sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), 'flywheel')) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), 'djangorestframework')) sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), 'examples')) import settings from django.core.management import setup_environ diff --git a/docs/index.rst b/docs/index.rst index 7a871a5c..7da3f017 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,10 +26,10 @@ Requirements Installation & Setup -------------------- -The django-rest-framework project is hosted as a `mercurial repository on bitbucket `_. +The django-rest-framework project is hosted as a `mercurial repository on bitbucket `_. To get a local copy of the repository use mercurial:: - hg clone https://tomchristie@bitbucket.org/tomchristie/flywheel + hg clone https://tomchristie@bitbucket.org/tomchristie/django-rest-framework To add django-rest-framework to a django project: @@ -43,27 +43,15 @@ Getting Started Often you'll want parts of your API to directly map to existing Models. At it's simplest this looks something like this... -``views.py``:: +``urls.py`` - from djangorestframework.modelresource import ModelResource, ModelRootResource - from models import MyModel +.. include:: ../examples/simpleexample/urls.py + :literal: - class MyModelRootResource(ModelRootResource): - """A create/list resource for MyModel.""" - allowed_methods = ('GET', 'POST') - model = MyModel +``views.py`` - class MyModelResource(ModelResource): - """A read/update/delete resource for MyModel.""" - allowed_methods = ('GET', 'PUT', 'DELETE') - model = MyModel - -``urls.py``:: - - urlpatterns += patterns('myapp.views', - url(r'^mymodel/$', 'MyModelRootResource'), - url(r'^mymodel/([^/]+)/$', 'MyModelResource'), - ) +.. include:: ../examples/simpleexample/views.py + :literal: Examples diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..77cdf485 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,8 @@ +# Documentation requires Django & Sphinx, and their dependencies... + +Django==1.2.4 +Jinja2==2.5.5 +Pygments==1.4 +Sphinx==1.0.7 +docutils==0.7 +wsgiref==0.1.2 diff --git a/examples/blogpost/views.py b/examples/blogpost/views.py index c5be2544..bfb53b5d 100644 --- a/examples/blogpost/views.py +++ b/examples/blogpost/views.py @@ -1,6 +1,6 @@ -from flywheel.response import Response, status -from flywheel.resource import Resource -from flywheel.modelresource import ModelResource, RootModelResource +from djangorestframework.response import Response, status +from djangorestframework.resource import Resource +from djangorestframework.modelresource import ModelResource, RootModelResource from blogpost.models import BlogPost, Comment BLOG_POST_FIELDS = ('created', 'title', 'slug', 'content', 'absolute_url', 'comment_url', 'comments_url') diff --git a/examples/objectstore/views.py b/examples/objectstore/views.py index 4681fe70..90bdb9e3 100644 --- a/examples/objectstore/views.py +++ b/examples/objectstore/views.py @@ -1,7 +1,7 @@ from django.conf import settings -from flywheel.resource import Resource -from flywheel.response import Response, status +from djangorestframework.resource import Resource +from djangorestframework.response import Response, status import pickle import os diff --git a/examples/pygments_api/views.py b/examples/pygments_api/views.py index d9082ada..e22705d9 100644 --- a/examples/pygments_api/views.py +++ b/examples/pygments_api/views.py @@ -1,8 +1,8 @@ from django.conf import settings -from flywheel.resource import Resource -from flywheel.response import Response, status -from flywheel.emitters import BaseEmitter +from djangorestframework.resource import Resource +from djangorestframework.response import Response, status +from djangorestframework.emitters import BaseEmitter from pygments.formatters import HtmlFormatter from pygments.lexers import get_lexer_by_name diff --git a/examples/requirements.txt b/examples/requirements.txt new file mode 100644 index 00000000..4ae9e3c7 --- /dev/null +++ b/examples/requirements.txt @@ -0,0 +1,6 @@ +# For the examples we need Django, pygments and httplib2... + +Django==1.2.4 +wsgiref==0.1.2 +Pygments==1.4 +httplib2==0.6.0 diff --git a/examples/settings.py b/examples/settings.py index 0ae3bf56..4aa5dd00 100644 --- a/examples/settings.py +++ b/examples/settings.py @@ -93,9 +93,10 @@ INSTALLED_APPS = ( 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', - 'django.contrib.admin', - 'flywheel', - 'blogpost', + #'django.contrib.admin', + 'djangorestframework', + 'simpleexample', 'objectstore', - 'pygments_api' + 'pygments_api', + 'blogpost', ) diff --git a/examples/simpleexample/__init__.py b/examples/simpleexample/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/simpleexample/models.py b/examples/simpleexample/models.py new file mode 100644 index 00000000..13867f61 --- /dev/null +++ b/examples/simpleexample/models.py @@ -0,0 +1,23 @@ +from django.db import models + +MAX_INSTANCES = 20 + +class MyModel(models.Model): + foo = models.BooleanField() + bar = models.IntegerField(help_text='Must be an integer.') + baz = models.CharField(max_length=32, help_text='Free text. Max length 32 chars.') + created = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('created',) + + def save(self, *args, **kwargs): + """For the purposes of the sandbox, limit the maximum number of stored models.""" + while MyModel.objects.all().count() > MAX_INSTANCES: + MyModel.objects.all()[0].delete() + super(MyModel, self).save(*args, **kwargs) + + @models.permalink + def get_absolute_url(self): + return ('simpleexample.views.MyModelResource', (self.pk,)) + diff --git a/examples/simpleexample/urls.py b/examples/simpleexample/urls.py new file mode 100644 index 00000000..d853ba5a --- /dev/null +++ b/examples/simpleexample/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls.defaults import patterns, url + +urlpatterns = patterns('simpleexample.views', + url(r'^$', 'MyModelRootResource'), + url(r'^([0-9]+)/$', 'MyModelResource'), +) diff --git a/examples/simpleexample/views.py b/examples/simpleexample/views.py new file mode 100644 index 00000000..1f113ac2 --- /dev/null +++ b/examples/simpleexample/views.py @@ -0,0 +1,18 @@ +from djangorestframework.modelresource import ModelResource, RootModelResource +from simpleexample.models import MyModel + +FIELDS = ('foo', 'bar', 'baz', 'absolute_url') + +class MyModelRootResource(RootModelResource): + """A create/list resource for MyModel. + Available for both authenticated and anonymous access for the purposes of the sandbox.""" + model = MyModel + allowed_methods = anon_allowed_methods = ('GET', 'POST') + fields = FIELDS + +class MyModelResource(ModelResource): + """A read/update/delete resource for MyModel. + Available for both authenticated and anonymous access for the purposes of the sandbox.""" + model = MyModel + allowed_methods = anon_allowed_methods = ('GET', 'PUT', 'DELETE') + fields = FIELDS diff --git a/examples/urls.py b/examples/urls.py index b1dec13d..2b8e6fcd 100644 --- a/examples/urls.py +++ b/examples/urls.py @@ -1,14 +1,27 @@ from django.conf.urls.defaults import patterns, include -from django.contrib import admin +#from django.contrib import admin +from djangorestframework.resource import Resource + +#admin.autodiscover() + +class RootResource(Resource): + allowed_methods = anon_allowed_methods = ('GET',) + + def get(self, request, auth): + return {'simple example': self.reverse('simpleexample.views.MyModelRootResource'), + 'pygments example': self.reverse('pygments_api.views.PygmentsRoot'), + 'object store example': self.reverse('objectstore.views.ObjectStoreRoot'), + 'blog post example': self.reverse('blogpost.views.BlogPostRoot'),} -admin.autodiscover() urlpatterns = patterns('', - (r'^pygments-example/', include('pygments_api.urls')), - (r'^blog-post-example/', include('blogpost.urls')), - (r'^object-store-example/', include('objectstore.urls')), + (r'^$', RootResource), + (r'^simple-example/', include('simpleexample.urls')), + (r'^object-store/', include('objectstore.urls')), + (r'^pygments/', include('pygments_api.urls')), + (r'^blog-post/', include('blogpost.urls')), (r'^accounts/login/$', 'django.contrib.auth.views.login'), (r'^accounts/logout/$', 'django.contrib.auth.views.logout'), - (r'^admin/doc/', include('django.contrib.admindocs.urls')), - (r'^admin/', include(admin.site.urls)), + #(r'^admin/doc/', include('django.contrib.admindocs.urls')), + #(r'^admin/', include(admin.site.urls)), ) diff --git a/flywheel/__init__.py b/flywheel/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/flywheel/authenticators.py b/flywheel/authenticators.py deleted file mode 100644 index 8de182de..00000000 --- a/flywheel/authenticators.py +++ /dev/null @@ -1,44 +0,0 @@ -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/flywheel/emitters.py b/flywheel/emitters.py deleted file mode 100644 index f548e1d9..00000000 --- a/flywheel/emitters.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Emitters are used to serialize a Resource's output into specific media types. -FlyWheel 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 flywheel.response import NoContent -from flywheel.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 FlyWheel 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/flywheel/modelresource.py b/flywheel/modelresource.py deleted file mode 100644 index d68ec79e..00000000 --- a/flywheel/modelresource.py +++ /dev/null @@ -1,402 +0,0 @@ -from django.forms import ModelForm -from django.db.models.query import QuerySet -from django.db.models import Model - -from flywheel.response import status, Response, ResponseException -from flywheel.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, content, *args, **kwargs): - # TODO: test creation on a non-existing resource url - all_kw_args = dict(content.items() + kwargs.items()) - 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, *args, **kwargs): - try: - instance = self.model.objects.get(**kwargs) - except self.model.DoesNotExist: - raise ResponseException(status.HTTP_404_NOT_FOUND) - - return instance - - def put(self, request, 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: - 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, *args, **kwargs): - try: - 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, *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, *args, **kwargs): - queryset = self.queryset if self.queryset else self.model.objects.all() - return queryset - diff --git a/flywheel/parsers.py b/flywheel/parsers.py deleted file mode 100644 index 98232a96..00000000 --- a/flywheel/parsers.py +++ /dev/null @@ -1,89 +0,0 @@ -from flywheel.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/flywheel/resource.py b/flywheel/resource.py deleted file mode 100644 index 2a8554f3..00000000 --- a/flywheel/resource.py +++ /dev/null @@ -1,451 +0,0 @@ -from django.contrib.sites.models import Site -from django.core.urlresolvers import reverse -from django.http import HttpResponse - -from flywheel import emitters, parsers, authenticators -from flywheel.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/flywheel/response.py b/flywheel/response.py deleted file mode 100644 index 4f23bb0a..00000000 --- a/flywheel/response.py +++ /dev/null @@ -1,125 +0,0 @@ -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/flywheel/templates/emitter.html b/flywheel/templates/emitter.html deleted file mode 100644 index d21350cd..00000000 --- a/flywheel/templates/emitter.html +++ /dev/null @@ -1,108 +0,0 @@ -{% load urlize_quoted_links %}{% load add_query_param %} - - - - - API - {{ resource.name }} - - -
- Django REST framework - {% if user.is_active %}Welcome, {{ user }}.{% if logout_url %} Log out{% endif %}{% else %}Not logged in {% if login_url %}Log in{% endif %}{% endif %} -
-
-

{{ resource.name }}

-

{{ resource.description|linebreaksbr }}

-
{{ 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 -
    - {% 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 %} -
-
- {% 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 %} -
-
- - {% 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 %} -
-
- {% csrf_token %} - - -
-
- {% endif %} - {% endif %} -
- - \ No newline at end of file diff --git a/flywheel/templates/emitter.txt b/flywheel/templates/emitter.txt deleted file mode 100644 index 1cc7d1d7..00000000 --- a/flywheel/templates/emitter.txt +++ /dev/null @@ -1,8 +0,0 @@ -{{ 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/flywheel/templatetags/__init__.py b/flywheel/templatetags/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/flywheel/templatetags/add_query_param.py b/flywheel/templatetags/add_query_param.py deleted file mode 100644 index 91c1a312..00000000 --- a/flywheel/templatetags/add_query_param.py +++ /dev/null @@ -1,17 +0,0 @@ -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/flywheel/templatetags/urlize_quoted_links.py b/flywheel/templatetags/urlize_quoted_links.py deleted file mode 100644 index 60088cf6..00000000 --- a/flywheel/templatetags/urlize_quoted_links.py +++ /dev/null @@ -1,100 +0,0 @@ -"""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(?:%s)*)(?P.*?)(?P(?:%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'(]*?)target=[^\s>]+') -html_gunk_re = re.compile(r'(?:
|<\/i>|<\/b>|<\/em>|<\/strong>|<\/?smallcaps>|<\/?uppercase>)', re.IGNORECASE) -hard_coded_bullets_re = re.compile(r'((?:

(?:%s).*?[a-zA-Z].*?

\s*)+)' % '|'.join([re.escape(x) for x in DOTS]), re.DOTALL) -trailing_empty_content_re = re.compile(r'(?:

(?: |\s|
)*?

\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 = '
%s' % (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) \ No newline at end of file diff --git a/flywheel/utils.py b/flywheel/utils.py deleted file mode 100644 index f9bbc0fe..00000000 --- a/flywheel/utils.py +++ /dev/null @@ -1,179 +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 -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) diff --git a/requirements.txt b/requirements.txt index 8144d828..84f0c4eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +# Django and pip are required if installing into a virtualenv environment... + Django==1.2.4 distribute==0.6.14 wsgiref==0.1.2 -- cgit v1.2.3