Switch to class based views. master
authorMikko Värri <vmj@linuxbox.fi>
Wed, 21 Sep 2011 21:10:54 +0000 (00:10 +0300)
committerMikko Värri <vmj@linuxbox.fi>
Wed, 21 Sep 2011 21:10:54 +0000 (00:10 +0300)
recycloid_api/http.py [new file with mode: 0644]
recycloid_api/middleware.py [new file with mode: 0644]
recycloid_api/urls.py
recycloid_api/views.py

diff --git a/recycloid_api/http.py b/recycloid_api/http.py
new file mode 100644 (file)
index 0000000..cd4048f
--- /dev/null
@@ -0,0 +1,168 @@
+import codecs
+import re
+
+from django.core.handlers.wsgi import STATUS_CODE_TEXT
+from django.http import HttpResponse
+from django.shortcuts import render_to_response as _render_to_response
+from django.template.loader import render_to_string
+
+
+response_template = {
+    'application/xml': 'recycloid_api/response.xml',
+    'application/json': 'recycloid_api/response.json',
+    }
+
+
+accept_delim_re = re.compile(r'\s*,\s*')
+accept_param_re = re.compile(r'\s*;\s*([^=\s]+)\s*=\s*([^;]+)')
+
+
+class HttpError(Exception):
+    """Base class for exceptions raised by REST views."""
+    status_code = 0
+
+class BadRequestError(HttpError):
+    status_code = 400
+
+class ForbiddenError(HttpError):
+    status_code = 403
+
+class NotFoundError(HttpError):
+    status_code = 404
+
+class NotAllowedError(HttpError):
+    status_code = 405
+
+    def __init__(self, allowed_methods):
+        HttpError.__init__(self, ', '.join(allowed_methods))
+
+class NotAcceptableError(HttpError):
+    status_code = 406
+
+    def __init__(self, accepted_mediatypes):
+        HttpError.__init__(self, ', '.join(accepted_mediatypes))
+
+class LengthRequiredError(HttpError):
+    status_code = 411
+
+class RequestEntityTooLarge(HttpError):
+    status_code = 413
+
+class UnsupportedMediaTypeError(HttpError):
+    status_code = 415
+
+
+class HttpResponseCreated(HttpResponse):
+    """Successful response for resource creation."""
+    status_code = 201
+
+    def __init__(self, location):
+        HttpResponse.__init__(self)
+        self['Location'] = location
+
+
+class HttpResponseNoContent(HttpResponse):
+    """Successful response for resource modification."""
+    status_code = 204
+
+
+class HttpResponseError(HttpResponse):
+    """Base class for error responses."""
+
+    def __init__(self, request, reason, *args, **kwargs):
+        context = { 'error': { 'code': self.status_code,
+                               'text': reason } }
+        mimetype, encoding = parse_media_type(request, 'HTTP_ACCEPT')
+        if mimetype:
+            kwargs['content_type'] = "%s; %s" % (mimetype, encoding)
+        HttpResponse.__init__(self, *args, **kwargs)
+        self.content = render_to_string(response_template[mimetype], context)
+
+
+class HttpResponse4xx(HttpResponseError):
+    """4xx error reponse."""
+
+    def __init__(self, request, e, *args, **kwargs):
+        self.status_code = e.status_code
+        reason = str(e)
+        if not reason:
+            reason = STATUS_CODE_TEXT[self.status_code]
+        HttpResponseError.__init__(self, request, reason, *args, **kwargs)
+
+
+class HttpResponseServerError(HttpResponseError):
+    """500 error response."""
+    status_code = 500
+
+    def __init__(self, request, e, *args, **kwargs):
+        reason = "%s: %s\n" % (e.__class__.__name__, str(e))
+        import traceback
+        reason += traceback.format_exc()
+        HttpResponseError.__init__(self, request, reason, *args, **kwargs)
+
+
+def parse_media_type(request, header):
+    """
+    Determines whether this request wants XML or JSON representation.
+
+    :param request: HttpRequest
+    :param header: key for request.META
+    :return: A tupple (mimetype, encoding) or (None, None)
+    """
+    if not header in request.META:
+        return ('application/xml', 'utf-8')
+
+    # Available mediatypes and their default params (q and charset)
+    mediatypes = {'*/*': [-1.0, 'utf-8'],
+                  'application/*': [-1.0, 'utf-8'],
+                  'application/xml': [-1.0, 'utf-8'],
+                  'application/json': [-1.0, 'utf-8'],
+                  }
+
+    # Get params from acceptable mediatypes
+    for mediatype in accept_delim_re.split(request.META[header]):
+        range = mediatype.split(';')[0].strip()
+        params = dict(accept_param_re.findall(mediatype))
+        for mediatype in mediatypes.keys():
+            if range == mediatype:
+                qparam = params.get('q', 1.0)
+                encoding = params.get('charset', 'utf-8')
+                try:
+                    qparam = float(qparam)
+                    codecs.getdecoder(encoding)
+                except ValueError:
+                    pass
+                except LookupError:
+                    pass
+                finally:
+                    qparam = min(max(0.0, qparam), 1.0)
+                    if mediatypes[mediatype][0] < qparam:
+                        mediatypes[mediatype][0] = qparam
+                        mediatypes[mediatype][1] = encoding
+
+    # Sort the available mediatypes by qparam
+    mediatypes = sorted([(k, v[0], v[1]) for k, v in mediatypes.items()],
+                        cmp=lambda x,y: cmp(x[1], y[1]),
+                        reverse=True)
+    if mediatypes[0][1] >= 0.0:
+        if mediatypes[0][0] in ('*/*', 'application/*', 'application/xml'):
+            return ('application/xml', mediatypes[0][2])
+        else:
+            return ('application/json', mediatypes[0][2])
+
+    # Accept header was there but nothing matched
+    raise UnsupportedMediaTypeError() # 415
+
+
+def render_to_response(request, context):
+    """
+    Determines the format of the response and returns that.
+
+    :param request: HttpRequest
+    :param context: context
+    :return: HttpResponse
+    """
+    mimetype, encoding = parse_media_type(request, 'HTTP_ACCEPT')
+    if not mimetype:
+        raise NotAcceptableError()
+    return _render_to_response(response_template[mimetype], context, mimetype=mimetype)
diff --git a/recycloid_api/middleware.py b/recycloid_api/middleware.py
new file mode 100644 (file)
index 0000000..6aa7a62
--- /dev/null
@@ -0,0 +1,12 @@
+
+from recycloid_api.http import HttpError, HttpResponse4xx, HttpResponseServerError
+
+
+class ErrorMiddleware:
+    """This middleware turns exceptions from REST views to an
+    appropriate error response."""
+
+    def process_exception(self, request, exception):
+        if isinstance(exception, HttpError):
+            return HttpResponse4xx(request, exception)
+        return HttpResponseServerError(request, exception)
index 46d3cc8..0499907 100644 (file)
@@ -1,5 +1,11 @@
 from django.conf.urls.defaults import *
 
+from recycloid_api.views import (ServersView, ServerView,
+                                 OwnersView, OwnerView,
+                                 StashesView, StashView,
+                                 ItemsView, ItemView,
+                                 ImagesView, ImageView)
+
 # 012345678-0123-0123-0123-0123456789AB
 uuid = "[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}"
 
@@ -10,25 +16,70 @@ item = r'(?P<item>' + uuid + r')'
 image = r'(?P<image>' + uuid + r')'
 
 urlpatterns = patterns('recycloid_api.views',
+        # The API entry point (links to top level collections)
         url(r'^$', 'index', name='recycloid-api-index'),
-        url(r'^servers/$', 'servers', name='recycloid-api-servers'),
-        url(r'^servers/' + server + r'/$', 'servers', name='recycloid-api-server'),
-        url(r'^servers/' + server + r'/owners/$', 'owners'),
-        url(r'^servers/' + server + r'/stashes/$', 'stashes'),
-        url(r'^servers/' + server + r'/items/$', 'items'),
-        url(r'^servers/' + server + r'/images/$', 'images'),
-        url(r'^owners/$', 'owners', name='recycloid-api-owners'),
-        url(r'^owners/' + owner + r'/$', 'owners', name='recycloid-api-owner'),
-        url(r'^owners/' + owner + r'/stashes/$', 'stashes'),
-        url(r'^owners/' + owner + r'/items/$', 'items'),
-        url(r'^owners/' + owner + r'/images/$', 'images'),
-        url(r'^stashes/$', 'stashes', name='recycloid-api-stashes'),
-        url(r'^stashes/' + stash + r'/$', 'stashes', name='recycloid-api-stash'),
-        url(r'^stashes/' + stash + r'/items/$', 'items'),
-        url(r'^stashes/' + stash + r'/images/$', 'images'),
-        url(r'^items/$', 'items', name='recycloid-api-items'),
-        url(r'^items/' + item + r'/$', 'items', name='recycloid-api-item'),
-        url(r'^items/' + item + r'/images/$', 'images'),
-        url(r'^images/$', 'images', name='recycloid-api-images'),
-        url(r'^images/' + image + r'/$', 'images', name='recycloid-api-image'),
+
+        # Top level collections (lists of resources, each linking to its canonical URL)
+        url(r'^servers/$', ServersView(), name='recycloid-api-servers'),
+        url(r'^owners/$',  OwnersView(),  name='recycloid-api-owners'),
+        url(r'^stashes/$', StashesView(), name='recycloid-api-stashes'),
+        url(r'^items/$',   ItemsView(),   name='recycloid-api-items'),
+        url(r'^images/$',  ImagesView(),  name='recycloid-api-images'),
+
+        # Canonical URLs for individual resources (these link to subcollections)
+        url(r'^servers/' + server + r'/$', ServerView(), name='recycloid-api-server'),
+        url(r'^owners/'  + owner  + r'/$', OwnerView(),  name='recycloid-api-owner'),
+        url(r'^stashes/' + stash  + r'/$', StashView(),  name='recycloid-api-stash'),
+        url(r'^items/'   + item   + r'/$', ItemView(),   name='recycloid-api-item'),
+        url(r'^images/'  + image  + r'/$', ImageView(),  name='recycloid-api-image'),
+
+        # Subcollections by server (lists of resources, each linking to its canonical URL)
+        url(r'^servers/' + server + r'/owners/$',  OwnersView()),
+        url(r'^servers/' + server + r'/stashes/$', StashesView()),
+        url(r'^servers/' + server + r'/items/$',   ItemsView()),
+        url(r'^servers/' + server + r'/images/$',  ImagesView()),
+
+        # Subcollections by owner (lists of resources, each linking to its canonical URL)
+        url(r'^owners/' + owner + r'/stashes/$', StashesView()),
+        url(r'^owners/' + owner + r'/items/$',   ItemsView()),
+        url(r'^owners/' + owner + r'/images/$',  ImagesView()),
+
+        # Subcollections by stash (lists of resources, each linking to its canonical URL)
+        url(r'^stashes/' + stash + r'/items/$',  ItemsView()),
+        url(r'^stashes/' + stash + r'/images/$', ImagesView()),
+
+        # Subcollections by item (lists of resources, each linking to its canonical URL)
+        url(r'^items/' + item + r'/images/$', ImagesView()),
+
+        # Redundant URLs for path like travelsal.  API never generates links to these.
+        url(r'^servers/' + server + r'/owners/' + owner + r'/$', OwnerView()),
+        url(r'^servers/' + server + r'/owners/' + owner + r'/stashes/$', StashesView()),
+        url(r'^servers/' + server + r'/owners/' + owner + r'/stashes/' + stash + r'/$', StashView()),
+        url(r'^servers/' + server + r'/owners/' + owner + r'/stashes/' + stash + r'/items/$', ItemsView()),
+        url(r'^servers/' + server + r'/owners/' + owner + r'/stashes/' + stash + r'/items/' + item + r'/$', ItemView()),
+        url(r'^servers/' + server + r'/owners/' + owner + r'/stashes/' + stash + r'/items/' + item + r'/images/$', ImagesView()),
+        url(r'^servers/' + server + r'/owners/' + owner + r'/stashes/' + stash + r'/items/' + item + r'/images/' + image + r'/$', ImageView()),
+        url(r'^servers/' + server + r'/stashes/' + stash + r'/$', StashView()),
+        url(r'^servers/' + server + r'/stashes/' + stash + r'/items/$', ItemsView()),
+        url(r'^servers/' + server + r'/stashes/' + stash + r'/items/' + item + r'/$', ItemView()),
+        url(r'^servers/' + server + r'/stashes/' + stash + r'/items/' + item + r'/images/$', ImagesView()),
+        url(r'^servers/' + server + r'/stashes/' + stash + r'/items/' + item + r'/images/' + image + r'/$', ImageView()),
+        url(r'^servers/' + server + r'/items/' + item + r'/$', ItemView()),
+        url(r'^servers/' + server + r'/items/' + item + r'/images/$', ImagesView()),
+        url(r'^servers/' + server + r'/items/' + item + r'/images/' + image + r'/$', ImageView()),
+        url(r'^servers/' + server + r'/images/' + image + r'/$', ImageView()),
+        url(r'^owners/' + owner + r'/stashes/' + stash + r'/$', StashView()),
+        url(r'^owners/' + owner + r'/stashes/' + stash + r'/items/$', ItemsView()),
+        url(r'^owners/' + owner + r'/stashes/' + stash + r'/items/' + item + r'/$', ItemView()),
+        url(r'^owners/' + owner + r'/stashes/' + stash + r'/items/' + item + r'/images/$', ImagesView()),
+        url(r'^owners/' + owner + r'/stashes/' + stash + r'/items/' + item + r'/images/' + image + r'/$', ImageView()),
+        url(r'^owners/' + owner + r'/items/' + item + r'/$', ItemView()),
+        url(r'^owners/' + owner + r'/items/' + item + r'/images/$', ImagesView()),
+        url(r'^owners/' + owner + r'/items/' + item + r'/images/' + image + r'/$', ImageView()),
+        url(r'^owners/' + owner + r'/images/' + image + r'/$', ImageView()),
+        url(r'^stashes/' + stash + r'/items/' + item + r'/$', ItemView()),
+        url(r'^stashes/' + stash + r'/items/' + item + r'/images/$', ImagesView()),
+        url(r'^stashes/' + stash + r'/items/' + item + r'/images/' + image + r'/$', ImageView()),
+        url(r'^stashes/' + stash + r'/images/' + image + r'/$', ImageView()),
+        url(r'^items/' + item + r'/images/' + image + r'/$', ImageView()),
 )
index caff247..fd13d71 100644 (file)
 import codecs
-import re
 
+from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
+from django.core.urlresolvers import reverse
 from django.http import HttpResponse
-from django.shortcuts import render_to_response
 
-from recycloid_api.parsers import JsonParser, XmlParser
-from recycloid_models.models import StashServer, StashOwner, Stash, StashItem, StashItemImage
+from recycloid_models.models import Server, Owner, Stash, Item, Image
 
+from recycloid_api import (http, parsers)
 
-response_template = {
-    'application/xml': 'recycloid_api/response.xml',
-    'application/json': 'recycloid_api/response.json',
-    }
+import settings
 
-accept_delim_re = re.compile(r'\s*,\s*')
-accept_param_re = re.compile(r'\s*;\s*([^=\s]+)\s*=\s*([^;]+)')
 
+def index(request):
+    """
+    Returns a list of links.
+
+    :param request: HttpRequest
+    :return: HttpReponse
+    """
+    if request.method != 'GET':
+        raise http.NotAllowedError(['GET']) # 405
 
-class HttpResponseNotAcceptable(HttpResponse):
-    status_code = 406
+    context = {'servers': [],
+               'owners': [],
+               'stashes': [],
+               'items': [],
+               'images': []}
 
-    def __init__(self, available_mediatypes):
-        HttpResponse.__init__(self)
-        self.content = available_mediatypes
+    return http.render_to_response(request, context)
 
 
-def __response_format(request):
-    """
-    Determines whether this request wants XML or JSON representation.
+class RecycloidView(object):
+    """Base class for all REST views.
 
-    :param request: HttpRequest
-    :return: "xml", "json" or None (if something unsupported is requested.
+    This class must be subclassed.  Subclass must define
+    allowed_methods and model class variables.
+
+    This class handles both collections and resources, but assumes
+    that collection subclasses do not allow resource methods
+    (PUT/PATCH/DELETE) and resource subclasses do not allow collection
+    methods (POST).
     """
-    if 'HTTP_ACCEPT' not in request.META:
-        return ('application/xml', 'utf-8')
-
-    # Available mediatypes and their default params (q and charset)
-    mediatypes = {'*/*': [-1.0, 'utf-8'],
-                  'application/*': [-1.0, 'utf-8'],
-                  'application/xml': [-1.0, 'utf-8'],
-                  'application/json': [-1.0, 'utf-8'],
-                  }
-
-    # Get params from acceptable mediatypes
-    for mediatype in accept_delim_re.split(request.META['HTTP_ACCEPT']):
-        range = mediatype.split(';')[0].strip()
-        params = dict(accept_param_re.findall(mediatype))
-        for mediatype in mediatypes.keys():
-            if range == mediatype:
-                qparam = params.get('q', 1.0)
-                encoding = params.get('charset', 'utf-8')
-                try:
-                    qparam = float(qparam)
-                    codecs.getdecoder(encoding)
-                except ValueError:
-                    pass
-                except LookupError:
-                    pass
-                finally:
-                    qparam = min(max(0.0, qparam), 1.0)
-                    if mediatypes[mediatype][0] < qparam:
-                        mediatypes[mediatype][0] = qparam
-                        mediatypes[mediatype][1] = encoding
-
-    # Sort the available mediatypes by qparam
-    mediatypes = sorted([(k, v[0], v[1]) for k, v in mediatypes.items()],
-                        cmp=lambda x,y: cmp(x[1], y[1]),
-                        reverse=True)
-    if mediatypes[0][1] >= 0.0:
-        if mediatypes[0][0] in ('*/*', 'application/*', 'application/xml'):
-            return ('application/xml', mediatypes[0][2])
-        else:
-            return ('application/json', mediatypes[0][2])
+    """URL name prefix."""
+    url_name_prefix = 'recycloid-api'
 
-    # Accept header was there but nothing matched
-    return (None, None)
+    """App name where the models live."""
+    model_app_name = 'recycloid_models'
 
+    """Allowed HTTP methods for this view instance.  E.g. ['GET','HEAD'].
 
-def __limit(request, queryset):
+    Concrete subclass must define this.
     """
-    Limits the QuerySet, if offset and/or limit is given in the request.
+    allowed_methods = []
 
-    :param request: HttpRequest
-    :param queryset: QuerySet to limit
-    :return: A dictionary containing the meta used to limit the query set, and the limited queryset.
+    """Model class for this view instance.
+
+    Concrete subclass must define this.
     """
-    offset = int(request.GET.get('offset', -1))
-    limit = int(request.GET.get('limit', -1))
+    model = None
 
-    if offset <= 0 and limit < 0:
-        return {}, queryset
+    def __init__(self):
+        self.resource_name = self.model._meta.verbose_name
+        # unicode() forces evaluation of the otherwise lazily evaluated string.
+        # TODO: Check what happens if the model supports something else than English.
+        self.collection_name = unicode(self.model._meta.verbose_name_plural)
 
-    if offset > 0 and limit < 0:
-        meta = {'offset': offset}
-        qry = request.GET.copy()
-        del qry['offset']
-        meta['previous'] = '%s?%s' % (request.path, qry.urlencode())
-        if meta['previous'].endswith('?'):
-            meta['previous'] = meta['previous'][:-1]
-        return meta, queryset[offset:]
-
-    if offset <= 0 and limit > 0:
-        meta = {'limit': limit}
-        qry = request.GET.copy()
-        qry['offset'] = limit
-        meta['next'] = '%s?%s' % (request.path, qry.urlencode())
-        return meta, queryset[:limit]
-
-    meta = {'offset': offset, 'limit': limit}
-    qry = request.GET.copy()
-    qry['offset'] = offset + limit
-    meta['next'] = '%s?%s' % (request.path, qry.urlencode())
-    qry['offset'] = offset - limit
-    if qry['offset'] <= 0:
-        del qry['offset']
-    meta['previous'] = '%s?%s' % (request.path, qry.urlencode())
-    return meta, queryset[offset:offset+limit]
-
-
-def __response(request, context):
-    """
-    Determines the format of the response and returns that.
+        self.resource_url_name = '%s-%s' % (self.url_name_prefix, self.resource_name)
+        self.collection_url_name = '%s-%s' % (self.url_name_prefix, self.collection_name)
 
-    :param request: HttpRequest
-    :param context: context
-    :return: HttpResponse
-    """
-    mimetype, encoding = __response_format(request)
-    if mimetype:
-        return render_to_response(response_template[mimetype], context, mimetype=mimetype)
-    else:
-        return HttpResponseNotAcceptable('application/xml, application/json')
+        self.add_perm = '%s.add_%s' % (self.model_app_name, self.resource_name)
+        self.change_perm = '%s.change_%s' % (self.model_app_name, self.resource_name)
+        self.del_perm = '%s.del_%s' % (self.model_app_name, self.resource_name)
 
+        return
 
-def __add(obj, key, reg, context):
-    """
-    Adds obj to context if it is not there already.
 
-    :param obj:  recycloid_models.models.IdentifiedModel instance
-    :param key:  'servers', 'owners', ... The key under which to add the object into context
-    :param reg:  dict where to register which objects have been added to context
-    :param objs:  list where to add obj, unless it already is there
-    """
-    if key not in reg or not isinstance(reg[key], dict):
-        reg[key] = {}
-    if key not in context or not isinstance(context[key], list):
-        context[key] = []
-    if obj.uuid not in reg[key]:
-        reg[key][obj.uuid] = True
-        context[key].append(obj)
-    return
-
-def __add_owner_related(owner, reg, context):
-    """
-    Adds stash owner related objects to the context. I.e. the server.
+    def __call__(self, request, **kwargs):
+        return self.dispatch(request, **kwargs)
 
-    :param owner: recycloid_models.models.StashOwner instance
-    :param reg: dict where to register which objects have been added to context
-    :param context: context
-    :return: None
-    """
-    __add(owner.server, 'servers', reg, context)
-    return
 
-def __add_stash_related(stash, reg, context):
-    """
-    Adds stash related objects to the context.  I.e. the stash owner
-    (and those related to that owner).
+    def dispatch(self, request, **kwargs):
+        """Dispatches a single request.
 
-    :param stash: recycloid_models.models.Stash instance
-    :param reg: dict where to register which objects have been added to context
-    :param context: context
-    :return: None
-    """
-    __add_owner_related(stash.owner, reg, context)
-    __add(stash.owner, 'owners', reg, context)
-    return
+        If the request method is not one of the allowed allowed_methods for
+        this view instance, 405 HTTP response ('Not Allowed') is
+        returned.  The error message will list the allowed methods.
 
-def __add_item_related(item, reg, context):
-    """
-    Adds stash item related objects to the context.  I.e. the stash
-    and those related to that stash.
+        This function dispatches the execution to an instance function
+        with the same name as the request method, only lower case.
+        E.g. GET is passed to this.get(request, **kwargs).
 
-    :param item: recycloid_models.models.StashItem instance
-    :param reg: dict where to register which objects have been added to context
-    :param context: context
-    :return: None
-    """
-    __add_stash_related(item.stash, reg, context)
-    __add(item.stash, 'stashes', reg, context)
-    return
+        :param request: HttpRequest
+        :param **kwargs: Keywork arguments (from URLConf)
+        :return: HttpResponse
+        """
+        if request.method not in self.allowed_methods:
+            raise http.NotAllowedError(self.allowed_methods) # 405
 
-def __add_image_related(image, reg, context):
-    """
-    Adds stash item image related objects to the context. I.e. the
-    item and those related to that item.
+        handler = getattr(self, request.method.lower())
+        return handler(request, **kwargs)
 
-    :param image: recycloid_models.models.StashItemImage instance
-    :param reg: dict where to register which objects have been added to context
-    :param context: context
-    :return: None
-    """
-    __add_item_related(image.item, reg, context)
-    __add(image.item, 'items', reg, context)
-    return
 
+    def options(self, request, **kwargs):
+        """Handles the OPTIONS HTTP method.
 
-def __object_list_response(request, objects, name, add_related_func):
-    """
-    Returns a list of objects (the objects QuerySet), possibly slicing
-    that list to a requested page (the 'offset' and 'limit' request
-    parameters), and possibly adding any referenced objects to the
-    response.
+        Always returns a 200 HTTP response ('OK') with a 'Allow' HTTP
+        response header.  The value of the header is a comma separated
+        list of allowed HTTP request methods for this view instance.
+        """
+        response = HttpResponse()
+        response['Allow'] = ','.join(self.allowed_methods)
+        return response
 
-    :param request: HttpRequest
-    :param objects: QuerySet
-    :param name: 'servers', 'owners', 'stashes', 'items', or 'images'
-    :param add_related_func: Function that can add the referenced objects
-    :return: HttpRequest
-    """
-    # [TODO] Search
 
-    if add_related_func:
-        objects = objects.select_related()
+    def head(self, request, **kwargs):
+        """Handles the HEAD HTTP method.
 
-    total = objects.count()
+        Currently does exactly the same as get() method.
 
-    meta, objects = __limit(request, objects)
-    meta['total'] = total
+        TODO: Check which part of Django strips the body from HEAD.
+        """
+        return self.get(request, **kwargs)
 
-    context = {'meta': meta, name: objects}
 
-    if add_related_func:
-        reg = {}
-        for obj in objects:
-            add_related_func(obj, reg, context)
+    def get(self, request, **kwargs):
+        """Handles the GET HTTP method.
 
-    return __response(request, context)
+        Basically, lists all instances of model.  If the URL
+        corresponds to subcollection, then the returned list contains
+        only matched instances.  If the URL corresponds to a single
+        resource, then just one instance is listed.
 
+        The returned list can be sliced to pages using the 'offset'
+        and 'limit' GET query parameters.  The 'offset' is zero-based
+        index of the first object to return.  It defaults to zero if
+        it is not given, or if the given value is less than zero.  The
+        'limit' is the number of objects to return.
 
-def index(request):
-    """
-    Returns a list of links.
+        Using the 'related' GET query parameter, referenced objects
+        can be included in the response.  For example, if requesting a
+        list of Items with 'related=1', then the response will contain
+        all the stashes references by the items.  With 'related=2',
+        also the owners referenced by any stashes are included.
+        I.e. the value of 'related' is the depth to which to include
+        related objects.  Default value is zero.
+        """
+        objects = self.model.objects.all()
 
-    :param request: HttpRequest
-    :return: HttpReponse
-    """
-    context = {'servers': [],
-               'owners': [],
-               'stashes': [],
-               'items': [],
-               'images': []}
-    return __response(request, context)
+        for resource_name,resource_uuid in kwargs.items():
+            if resource_uuid != None:
+                resource_uuid_path = self.__resource_uuid_path(self.model, resource_name)
+                print "GET: filter by '%s' (%s=%s)" % (resource_name, resource_uuid_path, resource_uuid)
+                filter = {}
+                filter[resource_uuid_path] = resource_uuid
+                objects = objects.filter(**filter)
 
+        # [TODO] Search
 
-def servers(request, server=None):
-    """
-    Returns a list of servers or one server.
+        try:
+            related = int(request.GET.get('related', 0))
+        except ValueError, e:
+            raise http.BadRequestError("Invalid integer for 'related' query parameter") # 400
 
-    :param request: HttpRequest
-    :param server: String representation of a UUID of a server or None.
-    :return: HttpResponse
-    """
-    if server:
-        servers = StashServer.objects.filter(uuid__exact=server)
-    else:
-        servers = StashServer.objects.all()
+        if related > 0:
+            objects = objects.select_related()
 
-    return __object_list_response(request, servers, 'servers', None)
+        total = objects.count()
 
+        meta, objects = self.__limit(request, objects)
+        meta['total'] = total
 
-def owners(request, server=None, owner=None):
-    """
-    Returns a list of owners or one owner.
+        context = {'meta': meta, self.collection_name: objects}
 
-    :param request: HttpRequest
-    :param server: String representation of a UUID of a server or None.
-    :param owner: String representation of a UUID of a owner or None.
-    :return: HttpResponse
-    """
-    if owner:
-        owners = StashOwner.objects.filter(uuid__exact=owner)
-    else:
-        owners = StashOwner.objects.all()
+        if related > 0:
+            registry = {}
+            for obj in objects:
+                self.__add_related(obj, registry, context, related)
+
+        return http.render_to_response(request, context)
+
+
+    def post(self, request, **kwargs):
+        """Handles the POST HTTP method.
+
+        Parses the resource from the HTTP request body and creates a
+        new model instance from it.
+
+        The resource must contain the UUID of the new instance.
+        Otherwise a 400 HTTP response ('Bad request') is returned.
+
+        The given UUID must be unique, otherwise a 409 HTTP response
+        ('Conflict') is returned.
+
+        The logged in user must had 'add' permissions for the model
+        and 'change' permission for any related model.  In case of
+        insufficient permissions, 403 HTTP response ('Forbidden') is
+        returned.
+        """
+        resource = self.parse_resource(request)
+
+        if '@uuid' not in resource:
+            raise http.BadRequestError("The UUID attribute required") # 400
+
+        uuid = resource['@uuid']
+        del resource['@uuid']
+
+        # The resource must not exists
+        try:
+            obj = self.model.objects.get(uuid__iexact=uuid)
+            raise http.ConflictError() # 409
+        except self.model.DoesNotExist:
+            pass
+
+        # User must have add permissions
+        if not request.user.has_perm(self.add_perm, obj):
+            raise http.ForbiddenError() # 403
+
+        obj = self.model.objects.create(uuid=uuid)
+
+        # Require all fields to be defined
+        self.__set_fields_from_parsed_resource(obj, resource, False)
+
+        self.__validate_and_save(obj)
+
+        return HttpResponseNoContent() # 204
+
+
+    def put(self, request, **kwargs):
+        """Handles the PUT HTTP method.
+        """
+        return self.patch(request, **kwargs)
+
+
+    def patch(self, request, **kwargs):
+        """Handles the PATCH HTTP method.
+        """
+        uuid = kwargs.get(self.resource_name, None)
+        assert(uuid != None)
+
+        resource = self.parse_resource(request)
+
+        if request.method == 'PUT' and '@uuid' not in resource:
+            raise http.BadRequestError('Incomplete PUT request (try PATCH instead)')
+
+        if '@uuid' in resource:
+            if resource['@uuid'] != uuid:
+                raise http.BadRequestError("The UUID attribute '%s' expected" % uuid) # 400
+            del resource['@uuid']
+
+        try:
+            obj = self.model.objects.get(uuid__iexact=uuid)
+        except self.model.DoesNotExist:
+            raise http.NotFoundError() # 404
+
+        if not request.user.has_perm(self.change_perm, obj):
+            raise http.ForbiddenError() # 403
+
+        if request.method == 'PUT':
+            self.__set_fields_from_parsed_resource(obj, resource, False)
+        else:
+            self.__set_fields_from_parsed_resource(obj, resource, True)
+
+        self.__validate_and_save(model_instance)
+
+        return HttpResponseNoContent() # 204
+
+
+    def delete(request, **kwargs):
+        """Handles the DELETE HTTP method.
+        """
+        uuid = kwargs.get(self.resource_name, None)
+        assert(uuid != None)
+
+        try:
+            obj = self.model.objects.get(uuid__iexact=uuid)
+        except self.model.DoesNotExist:
+            raise http.NotFoundError() # 404
+
+        if not request.user.has_perm(self.del_perm, obj):
+            raise http.NotFoundError() # 404
+
+        obj.delete()
+        return HttpResponseNoContent() # 204
+
+
+    def __limit(self, request, queryset,
+                offset_param='offset',
+                limit_param='limit',
+                previous_param='previous',
+                next_param='next'):
+        """
+        Limits the QuerySet, if offset and/or limit is given in the request.
+
+        :param request: HttpRequest
+        :param queryset: QuerySet to limit
+        :return: A dictionary containing the meta used to limit the query set, and the limited queryset.
+        """
+        offset = int(request.GET.get(offset_param, -1))
+        limit = int(request.GET.get(limit_param, -1))
+
+        if offset <= 0 and limit < 0:
+            return {}, queryset
+
+        if offset > 0 and limit < 0:
+            meta = {offset_param: offset}
+            qry = request.GET.copy()
+            del qry[offset_param]
+            meta[previous_param] = '%s?%s' % (request.path, qry.urlencode())
+            if meta[previous_param].endswith('?'):
+                meta[previous_param] = meta[previous_param][:-1]
+            return meta, queryset[offset:]
+
+        if offset <= 0 and limit > 0:
+            meta = {limit_param: limit}
+            qry = request.GET.copy()
+            qry[offset_param] = limit
+            meta[next_param] = '%s?%s' % (request.path, qry.urlencode())
+            return meta, queryset[:limit]
+
+        meta = {offset_param: offset, limit_param: limit}
+        qry = request.GET.copy()
+        qry[offset_param] = offset + limit
+        meta[next_param] = '%s?%s' % (request.path, qry.urlencode())
+        qry[offset_param] = offset - limit
+        if qry[offset_param] <= 0:
+            del qry[offset_param]
+            meta[previous_param] = '%s?%s' % (request.path, qry.urlencode())
+        return meta, queryset[offset:offset+limit]
+
+
+    def __set_fields_from_parsed_resource(self, obj, resource, patch):
+        """
+        :param obj: Model instance whose fields to set
+        :param resource: Dictionary of new values
+        :param patch: False if all fields must have a value in resource.  True otherwise.
+        """
+        for field in self.model._meta.fields:
+            if field.model._meta.app_name != self.model_app_name:
+                continue # out of scope; next field
+            if field.name == 'uuid':
+                continue # already handled; next field
+            if field.rel:
+                # Get the UUID of the related object. Also, if the
+                # UUID is mentioned in both, the URL and the resource,
+                # then check that they match.  NOTE: If the related
+                # UUID is only in the URL, following code accepts the
+                # resource even if patch is False.
+                try:
+                    related_uuid = resource['@%s' % field.name]
+                    del resource['@%s' % field.name]
+                    if field.name in kwargs and related_uuid != kwargs[field.name]:
+                        raise http.BadRequestError('%s UUID mismatch' % field.name) # 400
+                except KeyError, e:
+                    related_uuid = kwargs.get(field.name, None)
+                    if not related_uuid and not patch:
+                        raise http.BadRequestError('Field %s is required' % field.name) # 400
+                # Check that the related object exists and that user
+                # has change permission on it.
+                try:
+                    related_obj = field.rel.to.objects.get(uuid__iexact=related_uuid)
+                except field.rel.to.DoesNotExist:
+                    raise http.NotFoundError() # 404
+                if not request.user.has_perm(self.change_perm, related_obj):
+                    raise http.ForbiddenError() # 403
+                # Finally, link the objects
+                setattr(obj, field.name, related_obj)
+            if field.editable:
+                try:
+                    value = resource[field.name]
+                except KeyError, e:
+                    if not patch:
+                        raise http.BadRequestError('Field %s is required' % field.name) # 400
+                del entiry[field.name]
+                setattr(obj, field.name, value)
+        if resource.keys():
+            raise http.BadRequestError('Unknown fields: %s' % ', '.join(resource.keys())) # 400
+
+
+    def __validate_and_save(self, obj):
+        try:
+            obj.full_clean()
+        except ValidationError, e:
+            reason = u''
+            for field in e.message_dict.keys():
+                if field != NON_FIELD_ERRORS:
+                    for error in e.message_dict[field]:
+                        reason += "* %s: %s\n" % (field, error)
+            if NON_FIELD_ERRORS in e.message_dict:
+                for error in e.message_dict[NON_FIELD_ERRORS]:
+                    reason += "* %s\n" % error
+            raise http.BadRequestError(reason) # 400
+        obj.save()
+
+
+    def __resource_uuid_path(self, model, resource_name):
+        """
+        Finds and returns the filter path from model to
+        resource_name.
+
+        E.g. if given Item and 'owner', will return
+        'stash__owner__uuid__iexact'.
+
+        :param model:
+        :param related_name:
+        """
+        if model._meta.verbose_name == resource_name:
+            return 'uuid__iexact'
+        for field in model._meta.fields:
+            if field.rel and field.rel.to._meta.app_label == self.model_app_name:
+                if field.name == resource_name:
+                    return '%s__uuid__iexact' % resource_name
+                path = self.__resource_uuid_path(field.rel.to, resource_name)
+                if path:
+                    return '%s__%s' % (field.name, path)
+        return None
+
+
+    def __add_related(self, obj, registry, context, related):
+        """
+        Adds related objects to context.
+
+        E.g. if given an instance of Item, will add Stash,
+        Owner and Server to context.
+        """
+        if related >= 0:
+            for field in obj._meta.fields:
+                if field.rel and field.rel.to._meta.app_label == self.model_app_name:
+                    related_obj = getattr(obj, field.name)
+                    self.__add_related(related_obj, registry, context, related - 1)
+            # unicode() forces evaluation of the otherwise lazily evaluated string.
+            # TODO: Check what happens if the model supports something else than English.
+            collection_name = unicode(obj._meta.verbose_name_plural)
+            if collection_name != self.collection_name:
+                if collection_name not in registry:
+                    registry[collection_name] = {}
+                if collection_name not in context:
+                    context[collection_name] = []
+                if obj.uuid not in registry[collection_name]:
+                    registry[collection_name][obj.uuid] = True
+                    context[collection_name].append(obj)
+        return
+
+
+    def parse_resource(request):
+        """Parses and returns the resource from HTTP request body.
+
+        
+        """
+        mimetype, encoding = parse_media_type(request, 'CONTENT_TYPE')
+        if mimetype == 'application/xml':
+            ParserCls = parsers.XmlParser
+        else:
+            ParserCls = parsers.JsonParser
 
-    if server:
-        owners = owners.filter(server__uuid__exact=server)
+        try:
+            length = request.META['CONTENT_LENGTH']
+        except KeyError:
+            raise http.LengthRequiredError() # 411
 
-    return __object_list_response(request, owners, 'owners', __add_owner_related)
+        try:
+            max_length = settings.RECYCLOID_REQUEST_ENTITY_MAX_LENGTH
+        except AttributeError:
+            pass # no maximum set
+        else:
+            if length > max_length:
+                raise http.RequestEntityTooLarge() # 413
 
+        try:
+            request_post_data = codecs.getdecoder(encoding)(request.raw_post_data)[0]
+        except (LookupError, UnicodeDecodeError), e:
+            raise http.BadRequestError(unicode(e)) # 400
 
-def stashes(request, server=None, owner=None, stash=None):
+        parser = ParserCls()
+        try:
+            resource = parser.parse(request_post_data)
+        except ValueError, e:
+            raise http.BadRequestError(unicode(e)) # 400
+
+        if self.resource_name not in resource:
+            raise http.BadRequestError("Root element '%s' expected" % self.resource_name) # 400
+        if not isinstance(resource[self.resource_name], dict):
+            raise http.BadRequestError("Root element was invalid") # 400
+
+        return resource[self.resource_name]
+
+
+class RecycloidCollectionView(RecycloidView):
+    """Base class for collections.
+
+    Collections respond to GET requests with a list of resources.  A
+    POST can be used to add a new resource to the collection.
     """
-    Returns a list of stashes or one stash.
+    allowed_methods = ['GET','HEAD','POST','OPTIONS']
 
-    :param request: HttpRequest
-    :param server: String representation of a UUID of a server or None.
-    :param owner: String representation of a UUID of a owner or None.
-    :param stash: String representation of a UUID of a stash or None.
-    :return: HttpResponse
+class RecycloidResourceView(RecycloidView):
+    """Base class for resources.
+
+    Resources respond to GET request with details of a single
+    resource.  A PUT or PATCH can be used to modify the resource, and
+    DELETE can be used to remove the resource.
     """
-    if stash:
-        stashes = Stash.objects.filter(uuid__exact=stash)
-    else:
-        stashes = Stash.objects.all()
+    allowed_methods = ['GET','HEAD','PUT','PATCH','DELETE','OPTIONS']
 
-    if owner:
-        stashes = stashes.filter(owner__uuid__exact=owner)
 
-    if server:
-        stashes = stashes.filter(owner__server__uuid__exact=server)
+class ServersView(RecycloidCollectionView):
+    """Concrete servers collection.
 
-    return __object_list_response(request, stashes, 'stashes', __add_stash_related)
+    Responds to URLs that end with 'servers/'.
+    """
+    model = Server
 
+class OwnersView(RecycloidCollectionView):
+    """Concrete owners collection.
 
-def items(request, server=None, owner=None, stash=None, item=None):
+    Responds to URLs that end with 'owners/'.
     """
-    Returns a list of items or one item.
+    model = Owner
 
-    :param request: HttpRequest
-    :param server: String representation of a UUID of a server or None.
-    :param owner: String representation of a UUID of a owner or None.
-    :param stash: String representation of a UUID of a stash or None.
-    :param item: String representation of a UUID of a item or None.
-    :return: HttpResponse
+class StashesView(RecycloidCollectionView):
+    """Concrete stashes collection.
+
+    Responds to URLs that end with 'stashes/'.
     """
-    if item:
-        items = StashItem.objects.filter(uuid__exact=item)
-    else:
-        items = StashItem.objects.all()
+    model = Stash
 
-    if stash:
-        items = items.filter(stash__uuid__exact=stash)
+class ItemsView(RecycloidCollectionView):
+    """Concrete items collection.
 
-    if owner:
-        items = items.filter(stash__owner__uuid=owner)
+    Responds to URLs that end with 'items/'.
+    """
+    model = Item
+
+class ImagesView(RecycloidCollectionView):
+    """Concrete images collection.
 
-    if server:
-        items = items.filter(stash__owner__server__uuid=server)
+    Responds to URLs that end with 'images/'.
+    """
+    model = Image
 
-    return __object_list_response(request, items, 'items', __add_item_related)
 
+class ServerView(RecycloidResourceView):
+    """Concrete server resource.
 
-def images(request, server=None, owner=None, stash=None, item=None, image=None):
+    Responds to URLs that end with 'servers/<UUID>/'.
     """
-    Returns a list of images or one image.
+    model = Server
 
-    :param request: HttpRequest
-    :param server: String representation of a UUID of a server or None.
-    :param owner: String representation of a UUID of a owner or None.
-    :param stash: String representation of a UUID of a stash or None.
-    :param item: String representation of a UUID of a item or None.
-    :param image: String representation of a UUID of a image or None.
-    :return: HttpResponse
+class OwnerView(RecycloidResourceView):
+    """Concrete owner resource.
+
+    Responds to URLs that end with 'owner/<UUID>/'.
     """
-    if image:
-        images = StashItemImage.objects.filter(uuid__exact=image)
-    else:
-        images = StashItemImage.objects.all()
+    model = Owner
+
+class StashView(RecycloidResourceView):
+    """Concrete stash resource.
 
-    if item:
-        images = images.filter(item__uuid__exact=item)
+    Responds to URLs that end with 'stashes/<UUID>/'.
+    """
+    model = Stash
 
-    if stash:
-        images = images.filter(item__stash__uuid=stash)
+class ItemView(RecycloidResourceView):
+    """Concrete item resource.
 
-    if owner:
-        images = images.filter(item__stash__owner__uuid=owner)
+    Responds to URLs that end with 'items/<UUID>/'.
+    """
+    model = Item
 
-    if server:
-        images = images.filter(item__stash__owner__server__uuid=server)
+class ImageView(RecycloidResourceView):
+    """Concrete image resource.
 
-    return __object_list_response(request, images, 'images', __add_image_related)
+    Responds to URLs that end with 'images/<UUID>/'.
+    """
+    model = Image