First version of new RESTful AFE interface.  Includes a substantial library (under frontend/shared) and a definition of the interface for AFE (frontend/afe/resources.py).

If you want to see what this interface looks like, I encourage you to check out

http://your-autotest-server/afe/server/resources/?alt=json-html

>From there you can explore the entire interface through your browser (this is one of its great strengths).

For an introduction to the idea behind RESTful services, try http://bitworking.org/news/How_to_create_a_REST_Protocol.

This is still very much under development and there are plenty of TODOs, but it's working so I wanted to get it out there so we can start seeing how useful it turns out to be.

Signed-off-by: Steve Howard <showard@google.com>



git-svn-id: http://test.kernel.org/svn/autotest/trunk@4165 592f7852-d20e-0410-864c-8624ca9c26a4
diff --git a/frontend/shared/common.py b/frontend/shared/common.py
new file mode 100644
index 0000000..1edf302
--- /dev/null
+++ b/frontend/shared/common.py
@@ -0,0 +1,8 @@
+import os, sys
+dirname = os.path.dirname(sys.modules[__name__].__file__)
+autotest_dir = os.path.abspath(os.path.join(dirname, '..', '..'))
+client_dir = os.path.join(autotest_dir, "client")
+sys.path.insert(0, client_dir)
+import setup_modules
+sys.path.pop(0)
+setup_modules.setup(base_path=autotest_dir, root_module_name="autotest_lib")
diff --git a/frontend/shared/exceptions.py b/frontend/shared/exceptions.py
new file mode 100644
index 0000000..f8acbdf
--- /dev/null
+++ b/frontend/shared/exceptions.py
@@ -0,0 +1,19 @@
+from django import http
+
+class RequestError(Exception):
+    """Signifies that an error response should be returned."""
+
+    def __init__(self, code, entity_body=''):
+        if not entity_body.endswith('\n'):
+            entity_body += '\n'
+        self.response = http.HttpResponse(entity_body, status=code)
+
+
+class BadRequest(RequestError):
+    """An error was found with the request, 400 Bad Request will be returned.
+
+    The exception string should contain a description of the error.
+    """
+
+    def __init__(self, description):
+        super(BadRequest, self).__init__(400, description)
diff --git a/frontend/shared/json_html_formatter.py b/frontend/shared/json_html_formatter.py
new file mode 100644
index 0000000..b4b7364
--- /dev/null
+++ b/frontend/shared/json_html_formatter.py
@@ -0,0 +1,142 @@
+"""
+This module began as a more-or-less direct translation of jsonview.js from the
+JSONView project, http://code.google.com/p/jsonview.  Here's the original
+JSONView license:
+
+---
+MIT License
+
+Copyright (c) 2009 Benjamin Hollis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+---
+"""
+
+import re
+import simplejson
+
+_HTML_DOCUMENT_TEMPLATE = """
+<!DOCTYPE html>
+<html>
+<head>
+<title>JSON output</title>
+<link rel="stylesheet" type="text/css" href="/afe/server/static/jsonview.css">
+</head>
+<body>
+<div id="json">
+%s
+</div>
+</body>
+</html>
+"""
+
+class JsonHtmlFormatter(object):
+    def _html_encode(self, value):
+        if value is None:
+            return ''
+        return (str(value).replace('&', '&amp;').replace('"', '&quot;')
+                .replace('<', '&lt;').replace('>', '&gt;'))
+
+
+    def _decorate_with_span(self, value, className):
+        return '<span class="%s">%s</span>' % (
+                className, self._html_encode(value))
+
+
+    # Convert a basic JSON datatype (number, string, boolean, null, object,
+    # array) into an HTML fragment.
+    def _value_to_html(self, value):
+        if value is None:
+            return self._decorate_with_span('null', 'null')
+        elif isinstance(value, list):
+            return self._array_to_html(value)
+        elif isinstance(value, dict):
+            return self._object_to_html(value)
+        elif isinstance(value, bool):
+            return self._decorate_with_span(str(value).lower(), 'bool')
+        elif isinstance(value, (int, float)):
+            return self._decorate_with_span(value, 'num')
+        else:
+            assert isinstance(value, basestring)
+            return self._decorate_with_span('"%s"' % value, 'string')
+
+
+    # Convert an array into an HTML fragment
+    def _array_to_html(self, array):
+        if not array:
+            return '[ ]'
+
+        output = ['[<ul class="array collapsible">']
+        for value in array:
+            output.append('<li>')
+            output.append(self._value_to_html(value))
+            output.append('</li>')
+        output.append('</ul>]')
+        return ''.join(output)
+
+
+    def _link_href(self, href):
+        if '?' in href:
+            joiner = '&amp;'
+        else:
+            joiner = '?'
+        return href + joiner + 'alt=json-html'
+
+
+    # Convert a JSON object to an HTML fragment
+    def _object_to_html(self, json_object):
+        if not json_object:
+            return '{ }'
+
+        output = ['{<ul class="obj collapsible">']
+        for key, value in json_object.iteritems():
+            assert isinstance(key, basestring)
+            output.append('<li>')
+            output.append('<span class="prop">%s</span>: '
+                          % self._html_encode(key))
+            value_html = self._value_to_html(value)
+            if key == 'href':
+                assert isinstance(value, basestring)
+                output.append('<a href="%s">%s</a>' % (self._link_href(value),
+                                                       value_html))
+            else:
+                output.append(value_html)
+            output.append('</li>')
+        output.append('</ul>}')
+        return ''.join(output)
+
+
+    # Convert a whole JSON object into a formatted HTML document.
+    def json_to_html(self, json_value):
+        return _HTML_DOCUMENT_TEMPLATE % self._value_to_html(json_value)
+
+
+class JsonToHtmlMiddleware(object):
+    def process_response(self, request, response):
+        if response['Content-type'] != 'application/json':
+            return response
+        if request.GET.get('alt', None) != 'json-html':
+            return response
+
+        json_value = simplejson.loads(response.content)
+        html = JsonHtmlFormatter().json_to_html(json_value)
+        response.content = html
+        response['Content-type'] = 'text/html'
+        response['Content-length'] = len(html)
+        return response
diff --git a/frontend/shared/query_lib.py b/frontend/shared/query_lib.py
new file mode 100644
index 0000000..87ebac2
--- /dev/null
+++ b/frontend/shared/query_lib.py
@@ -0,0 +1,161 @@
+from autotest_lib.frontend.shared import exceptions
+
+class ConstraintError(Exception):
+    """Raised when an error occurs applying a Constraint."""
+
+
+class BaseQueryProcessor(object):
+    # maps selector name to (selector, constraint)
+    _selectors = None
+    _alias_counter = 0
+
+
+    @classmethod
+    def _initialize_selectors(cls):
+        if not cls._selectors:
+            cls._selectors = {}
+            cls._add_all_selectors()
+
+
+    @classmethod
+    def _add_all_selectors(cls):
+        """
+        Subclasses should override this to define which selectors they accept.
+        """
+        pass
+
+
+    @classmethod
+    def _add_field_selector(cls, name, field=None, value_transform=None,
+                            doc=None):
+        if not field:
+            field = name
+        cls._add_selector(Selector(name, doc),
+                          _FieldConstraint(field, value_transform))
+
+
+    @classmethod
+    def _add_related_existence_selector(cls, name, model, field, doc=None):
+        cls._add_selector(Selector(name, doc),
+                          _RelatedExistenceConstraint(model, field,
+                                                      cls.make_alias))
+
+
+    @classmethod
+    def _add_selector(cls, selector, constraint):
+        cls._selectors[selector.name] = (selector, constraint)
+
+
+    @classmethod
+    def make_alias(cls):
+        cls._alias_counter += 1
+        return 'alias%s' % cls._alias_counter
+
+
+    @classmethod
+    def selectors(cls):
+        cls._initialize_selectors()
+        return tuple(selector for selector, constraint
+                     in cls._selectors.itervalues())
+
+
+    @classmethod
+    def has_selector(cls, selector_name):
+        cls._initialize_selectors()
+        return selector_name in cls._selectors
+
+
+    def apply_selector(self, queryset, selector_name, value,
+                       comparison_type='equals', is_inverse=False):
+        _, constraint = self._selectors[selector_name]
+        try:
+            return constraint.apply_constraint(queryset, value, comparison_type,
+                                               is_inverse)
+        except ConstraintError, exc:
+            raise exceptions.BadRequest('Selector %s: %s'
+                                        % (selector_name, exc))
+
+
+    # common value conversions
+
+    @classmethod
+    def read_boolean(cls, boolean_input):
+        if boolean_input.lower() == 'true':
+            return True
+        if boolean_input.lower() == 'false':
+            return False
+        raise exceptions.BadRequest('Invalid input for boolean: %r'
+                                    % boolean_input)
+
+
+class Selector(object):
+    def __init__(self, name, doc):
+        self.name = name
+        self.doc = doc
+
+
+class Constraint(object):
+    def apply_constraint(self, queryset, value, comparison_type, is_inverse):
+        raise NotImplementedError
+
+
+class _FieldConstraint(Constraint):
+    def __init__(self, field, value_transform=None):
+        self._field = field
+        self._value_transform = value_transform
+
+
+    _COMPARISON_MAP = {
+            'equals': 'exact',
+            'lt': 'lt',
+            'le': 'lte',
+            'gt': 'gt',
+            'ge': 'gte',
+            'contains': 'contains',
+            'startswith': 'startswith',
+            'endswith': 'endswith',
+            'in': 'in',
+            }
+
+
+    def apply_constraint(self, queryset, value, comparison_type, is_inverse):
+        if self._value_transform:
+            value = self._value_transform(value)
+
+        kwarg_name = str(self._field + '__' +
+                         self._COMPARISON_MAP[comparison_type])
+
+        if is_inverse:
+            return queryset.exclude(**{kwarg_name: value})
+        else:
+            return queryset.filter(**{kwarg_name: value})
+
+
+class _RelatedExistenceConstraint(Constraint):
+    def __init__(self, model, field, make_alias_fn):
+        self._model = model
+        self._field = field
+        self._make_alias_fn = make_alias_fn
+
+
+    def apply_constraint(self, queryset, value, comparison_type, is_inverse):
+        if comparison_type not in (None, 'equals'):
+            raise ConstraintError('Can only use equals or not equals with '
+                                  'this selector')
+        related_query = self._model.objects.filter(**{self._field: value})
+        if not related_query:
+            raise ConstraintError('%s %s not found' % (self._model_name, value))
+        alias = self._make_alias_fn()
+        queryset = queryset.model.objects.join_custom_field(queryset,
+                                                            related_query,
+                                                            alias)
+        if is_inverse:
+            condition = '%s.%s IS NULL'
+        else:
+            condition = '%s.%s IS NOT NULL'
+        condition %= (alias,
+                      queryset.model.objects.key_on_joined_table(related_query))
+
+        queryset = queryset.model.objects.add_where(queryset, condition)
+
+        return queryset
diff --git a/frontend/shared/resource_lib.py b/frontend/shared/resource_lib.py
new file mode 100644
index 0000000..cf98acd
--- /dev/null
+++ b/frontend/shared/resource_lib.py
@@ -0,0 +1,466 @@
+import cgi, datetime, time, urllib
+from django import http
+from django.core import exceptions, urlresolvers
+from django.utils import simplejson
+from autotest_lib.frontend.shared import exceptions, query_lib
+from autotest_lib.frontend.afe import model_logic
+
+
+_JSON_CONTENT_TYPE = 'application/json'
+
+
+def _resolve_class_path(class_path):
+    module_path, class_name = class_path.rsplit('.', 1)
+    module = __import__(module_path, {}, {}, [''])
+    return getattr(module, class_name)
+
+
+_NO_VALUE_SPECIFIED = object()
+
+class _InputDict(dict):
+    def get(self, key, default=_NO_VALUE_SPECIFIED):
+        return super(_InputDict, self).get(key, default)
+
+
+    @classmethod
+    def remove_unspecified_fields(cls, field_dict):
+        return dict((key, value) for key, value in field_dict.iteritems()
+                    if value is not _NO_VALUE_SPECIFIED)
+
+
+class Resource(object):
+    _permitted_methods = None # subclasses must override this
+
+
+    def __init__(self):
+        assert self._permitted_methods
+
+
+    @classmethod
+    def dispatch_request(cls, request, *args, **kwargs):
+        # handle a request directly
+        try:
+            instance = cls.from_uri_args(*args, **kwargs)
+        except exceptions.ObjectDoesNotExist, exc:
+            raise http.Http404(exc)
+        return instance.handle_request(request)
+
+
+    def handle_request(self, request):
+        if request.method.upper() not in self._permitted_methods:
+            return http.HttpResponseNotAllowed(self._permitted_methods)
+
+        handler = getattr(self, request.method.lower())
+        try:
+            return handler(request)
+        except exceptions.RequestError, exc:
+            return exc.response
+
+
+    # the handler methods below only need to be overridden if the resource
+    # supports the method
+
+    def get(self, request):
+        """Handle a GET request.
+
+        @returns an HttpResponse
+        """
+        raise NotImplementedError
+
+
+    def post(self, request):
+        """Handle a POST request.
+
+        @returns an HttpResponse
+        """
+        raise NotImplementedError
+
+
+    def put(self, request):
+        """Handle a PUT request.
+
+        @returns an HttpResponse
+        """
+        raise NotImplementedError
+
+
+    def delete(self, request):
+        """Handle a DELETE request.
+
+        @returns an HttpResponse
+        """
+        raise NotImplementedError
+
+
+    @classmethod
+    def from_uri_args(cls):
+        """Construct an instance from URI args.
+
+        Default implementation for resources with no URI args.
+        """
+        return cls()
+
+
+    def _uri_args(self):
+        """Return (args, kwargs) for a URI reference to this resource.
+
+        Default implementation for resources with no URI args.
+        """
+        return (), {}
+
+
+    def _query_parameters(self):
+        """Return sequence of tuples (name, description) for query parameters.
+
+        Documents the available query parameters for GETting this resource.
+        Default implementation for resources with no parameters.
+        """
+        return ()
+
+
+    def href(self):
+        """Return URI to this resource."""
+        args, kwargs = self._uri_args()
+        return urlresolvers.reverse(self.dispatch_request, args=args,
+                                    kwargs=kwargs)
+
+
+    @classmethod
+    def resolve_uri(cls, uri):
+        view_method, args, kwargs = urlresolvers.resolve(uri)
+        resource_class = view_method.im_self # class owning this classmethod
+        return resource_class.from_uri_args(*args, **kwargs)
+
+
+    @classmethod
+    def resolve_link(cls, link):
+        if isinstance(link, dict):
+            uri = link['href']
+        elif isinstance(link, basestring):
+            uri = link
+        else:
+            raise exceptions.BadRequest('Unable to understand link %s' % link)
+        return cls.resolve_uri(uri)
+
+
+    def link(self):
+        return {'href': self.href()}
+
+
+    def _query_parameters_response(self):
+        return dict((name, description)
+                    for name, description in self._query_parameters())
+
+
+    def _basic_response(self, content):
+        """Construct and return a simple 200 response."""
+        assert isinstance(content, dict)
+        query_parameters = self._query_parameters_response()
+        if query_parameters:
+            content['query_parameters'] = query_parameters
+        encoded_content = simplejson.dumps(content)
+        return http.HttpResponse(encoded_content,
+                                 content_type=_JSON_CONTENT_TYPE)
+
+
+    @classmethod
+    def _decoded_input(cls, request):
+        content_type = request.META.get('CONTENT_TYPE', _JSON_CONTENT_TYPE)
+        raw_data = request.raw_post_data
+        if content_type == _JSON_CONTENT_TYPE:
+            try:
+                raw_dict = simplejson.loads(raw_data)
+            except ValueError, exc:
+                raise exceptions.BadRequest('Error decoding request body: '
+                                            '%s\n%r' % (exc, raw_data))
+        elif content_type == 'application/x-www-form-urlencoded':
+            cgi_dict = cgi.parse_qs(raw_data) # django won't do this for PUT
+            raw_dict = {}
+            for key, values in cgi_dict.items():
+                value = values[-1] # take last value if multiple were given
+                try:
+                    # attempt to parse numbers, booleans and nulls
+                    raw_dict[key] = simplejson.loads(value)
+                except ValueError:
+                    # otherwise, leave it as a string
+                    raw_dict[key] = value
+        else:
+            raise exceptions.RequestError(415, 'Unsupported media type: %s'
+                                          % content_type)
+
+        return _InputDict(raw_dict)
+
+
+    def _format_datetime(self, date_time):
+        """Return ISO 8601 string for the given datetime"""
+        if date_time is None:
+            return None
+        timezone_hrs = time.timezone / 60 / 60  # convert seconds to hours
+        if timezone_hrs >= 0:
+            timezone_join = '+'
+        else:
+            timezone_join = '' # minus sign comes from number itself
+        timezone_spec = '%s%s:00' % (timezone_join, timezone_hrs)
+        return date_time.strftime('%Y-%m-%dT%H:%M:%S') + timezone_spec
+
+
+    @classmethod
+    def _check_for_required_fields(cls, input_dict, fields):
+        assert isinstance(fields, (list, tuple)), fields
+        missing_fields = ', '.join(field for field in fields
+                                   if field not in input_dict)
+        if missing_fields:
+            raise exceptions.BadRequest('Missing input: ' + missing_fields)
+
+
+class Entry(Resource):
+    class NullEntry(object):
+        def link(self):
+            return None
+
+
+        def short_representation(self):
+            return None
+
+    _null_entry = NullEntry()
+
+
+    _permitted_methods = ('GET', 'PUT', 'DELETE')
+
+
+    # sublcasses must define this class to support querying
+    QueryProcessor = query_lib.BaseQueryProcessor
+
+
+    def __init__(self, instance):
+        super(Entry, self).__init__()
+        self.instance = instance
+
+
+    @classmethod
+    def from_optional_instance(cls, instance):
+        if instance is None:
+            return cls._null_entry
+        return cls(instance)
+
+
+    def short_representation(self):
+        return self.link()
+
+
+    def full_representation(self):
+        return self.short_representation()
+
+
+    def get(self, request):
+        return self._basic_response(self.full_representation())
+
+
+    def put(self, request):
+        try:
+            self.update(self._decoded_input(request))
+        except model_logic.ValidationError, exc:
+            raise exceptions.BadRequest('Invalid input: %s' % exc)
+        return self._basic_response(self.full_representation())
+
+
+    def delete(self, request):
+        self.instance.delete()
+        return http.HttpResponse(status=204) # No content
+
+
+    def create_instance(self, input_dict, containing_collection):
+        raise NotImplementedError
+
+
+    def update(self, input_dict):
+        raise NotImplementedError
+
+
+class Collection(Resource):
+    _DEFAULT_ITEMS_PER_PAGE = 50
+
+    _permitted_methods=('GET', 'POST')
+
+    # subclasses must override these
+    queryset = None # or override _fresh_queryset() directly
+    entry_class = None
+
+
+    def __init__(self):
+        super(Collection, self).__init__()
+        assert self.entry_class is not None
+        if isinstance(self.entry_class, basestring):
+            type(self).entry_class = _resolve_class_path(self.entry_class)
+
+        self._query_processor = self.entry_class.QueryProcessor()
+
+
+    def _fresh_queryset(self):
+        assert self.queryset is not None
+        # always copy the queryset before using it to avoid caching
+        return self.queryset.all()
+
+
+    def _representation(self, entry_instances):
+        members = []
+        for instance in entry_instances:
+            entry = self.entry_class(instance)
+            members.append(entry.short_representation())
+
+        rep = self.link()
+        rep.update({'members': members})
+        return rep
+
+
+    def _read_int_parameter(self, query_dict, name, default):
+        if name not in query_dict:
+            return default
+        input_value = query_dict[name]
+        try:
+            return int(input_value)
+        except ValueError:
+            raise exceptions.BadRequest('Invalid non-numeric value for %s: %r'
+                                        % (name, input_value))
+
+
+    def _apply_form_query(self, request, queryset):
+        """Apply any query selectors passed as form variables."""
+        for parameter, values in request.GET.lists():
+            if not self._query_processor.has_selector(parameter):
+                continue
+            for value in values: # forms keys can have multiple values
+                queryset = self._query_processor.apply_selector(queryset,
+                                                                parameter,
+                                                                value)
+        return queryset
+
+
+    def _filtered_queryset(self, request):
+        return self._apply_form_query(request, self._fresh_queryset())
+
+
+    def get(self, request):
+        queryset = self._filtered_queryset(request)
+
+        items_per_page = self._read_int_parameter(request.GET, 'items_per_page',
+                                                  self._DEFAULT_ITEMS_PER_PAGE)
+        start_index = self._read_int_parameter(request.GET, 'start_index', 0)
+        page = queryset[start_index:(start_index + items_per_page)]
+
+        rep = self._representation(page)
+        selector_dict = dict((selector.name, selector.doc)
+                             for selector
+                             in self.entry_class.QueryProcessor.selectors())
+        rep.update({'total_results': len(queryset),
+                    'start_index': start_index,
+                    'items_per_page': items_per_page,
+                    'filtering_selectors': selector_dict})
+        return self._basic_response(rep)
+
+
+    def full_representation(self):
+        # careful, this rep can be huge for large collections
+        return self._representation(self._fresh_queryset())
+
+
+    def post(self, request):
+        input_dict = self._decoded_input(request)
+        try:
+            instance = self.entry_class.create_instance(input_dict, self)
+            entry = self.entry_class(instance)
+            entry.update(input_dict)
+        except model_logic.ValidationError, exc:
+            raise exceptions.BadRequest('Invalid input: %s' % exc)
+        # RFC 2616 specifies that we provide the new URI in both the Location
+        # header and the body
+        response = http.HttpResponse(status=201, # Created
+                                     content=entry.href())
+        response['Location'] = entry.href()
+        return response
+
+
+class Relationship(Collection):
+    _permitted_methods=('GET', 'PUT')
+
+    base_entry_class = None # subclasses must override this
+
+
+    def __init__(self, base_entry):
+        assert self.base_entry_class
+        if isinstance(self.base_entry_class, basestring):
+            type(self).base_entry_class = _resolve_class_path(
+                    self.base_entry_class)
+        assert isinstance(base_entry, self.base_entry_class)
+        self.base_entry = base_entry
+        super(Relationship, self).__init__()
+
+
+    def _fresh_queryset(self):
+        """Return a QuerySet for this relationship using self.base_entry."""
+        raise NotImplementedError
+
+
+    @classmethod
+    def from_uri_args(cls, *args, **kwargs):
+        base_entry = cls.base_entry_class.from_uri_args(*args, **kwargs)
+        return cls(base_entry)
+
+
+    def _uri_args(self):
+        return self.base_entry._uri_args()
+
+
+    @classmethod
+    def _read_hrefs(cls, links):
+        return [link['href'] for link in links]
+
+
+    @classmethod
+    def _input_collection_hrefs(cls, input_data):
+        """Get the members of a user-provided collection.
+
+        Tries to be flexible about formats accepted from the user.
+        @returns a list of hrefs
+        """
+        if isinstance(input_data, dict) and 'members' in input_data:
+            # this mirrors the output representation for collections
+            # guard against accidental truncation of the relationship due to
+            # paging
+            is_partial_collection = ('total_results' in input_data
+                                     and 'items_per_page' in input_data
+                                     and input_data['total_results'] >
+                                         input_data['items_per_page'])
+            if is_partial_collection:
+                raise exceptions.BadRequest('You must retreive the full '
+                                            'collection to perform updates')
+
+            return cls._read_hrefs(input_data['members'])
+        if isinstance(input_data, list):
+            if not input_data:
+                return input_data
+            if isinstance(input_data[0], dict):
+                # assume it's a list of links
+                return cls._read_hrefs(input_data)
+            if isinstance(input_data[0], basestring):
+                # assume it's a list of hrefs
+                return input_data
+        raise exceptions.BadRequest('Cannot understand collection in input: %r'
+                                    % input_data)
+
+
+    def put(self, request):
+        input_data = self._decoded_input(request)
+        self.update(input_data)
+        return self.get(request)
+
+
+    def update(self, input_data):
+        hrefs = self._input_collection_hrefs(input_data)
+        instances = [self.entry_class.resolve_uri(href).instance
+                     for href in hrefs]
+        self._update_relationship(instances)
+
+
+    def _update_relationship(self, related_instances):
+        raise NotImplementedError
diff --git a/frontend/shared/rest_client.py b/frontend/shared/rest_client.py
new file mode 100644
index 0000000..d6aca5d
--- /dev/null
+++ b/frontend/shared/rest_client.py
@@ -0,0 +1,159 @@
+import logging, pprint, re, urllib
+import httplib2
+from django.utils import simplejson
+
+
+_RESOURCE_DIRECTORY_PATH = '/afe/server/resources/'
+
+
+_http = httplib2.Http()
+
+
+class RestClientError(Exception):
+    pass
+
+
+class ClientError(Exception):
+    pass
+
+
+class ServerError(Exception):
+    pass
+
+
+class Response(object):
+    def __init__(self, httplib_response, httplib_content):
+        self.status = int(httplib_response['status'])
+        self.headers = httplib_response
+        self.entity_body = httplib_content
+
+
+    def decoded_body(self):
+        return simplejson.loads(self.entity_body)
+
+
+    def __str__(self):
+        return '\n'.join([str(self.status), self.entity_body])
+
+
+class Resource(object):
+    def __init__(self, base_uri, representation_dict):
+        self._base_uri = base_uri
+
+        assert 'href' in representation_dict
+        for key, value in representation_dict.iteritems():
+            setattr(self, str(key), value)
+
+
+    def __repr__(self):
+        return 'Resource(%r)' % self._representation()
+
+
+    def pprint(self):
+        # pretty-print support for debugging/interactive use
+        pprint.pprint(self._representation())
+
+
+    @classmethod
+    def directory(cls, base_uri):
+        directory = cls(base_uri, {'href': _RESOURCE_DIRECTORY_PATH})
+        return directory.get()
+
+
+    def _read_representation(self, value):
+        # recursively convert representation dicts to Resource objects
+        if isinstance(value, list):
+            return [self._read_representation(element) for element in value]
+        if isinstance(value, dict):
+            converted_dict = dict((key, self._read_representation(sub_value))
+                                  for key, sub_value in value.iteritems())
+            if 'href' in converted_dict:
+                return type(self)(self._base_uri, converted_dict)
+            return converted_dict
+        return value
+
+
+    def _write_representation(self, value):
+        # recursively convert Resource objects to representation dicts
+        if isinstance(value, list):
+            return [self._write_representation(element) for element in value]
+        if isinstance(value, dict):
+            return dict((key, self._write_representation(sub_value))
+                        for key, sub_value in value.iteritems())
+        if isinstance(value, Resource):
+            return value._representation()
+        return value
+
+
+    def _representation(self):
+        return dict((key, self._write_representation(value))
+                    for key, value in self.__dict__.iteritems()
+                    if not key.startswith('_')
+                    and not callable(value))
+
+
+    def _request(self, method, query_parameters=None, encoded_body=None):
+        uri_parts = []
+        if not re.match(r'^https?://', self.href):
+            uri_parts.append(self._base_uri)
+        uri_parts.append(self.href)
+        if query_parameters:
+            query_string = urllib.urlencode(query_parameters)
+            uri_parts.extend(['?', query_string])
+
+        if encoded_body:
+            entity_body = simplejson.dumps(encoded_body)
+        else:
+            entity_body = None
+
+        full_uri = ''.join(uri_parts)
+        logging.debug('%s %s', method, full_uri)
+        if entity_body:
+            logging.debug(entity_body)
+        headers, response_body = _http.request(
+                ''.join(uri_parts), method, body=entity_body,
+                headers={'Content-Type': 'application/json'})
+        logging.debug('Response: %s', headers['status'])
+
+        response = Response(headers, response_body)
+        if 300 <= response.status < 400: # redirection
+            raise NotImplementedError(str(response)) # TODO
+        if 400 <= response.status < 500:
+            raise ClientError(str(response))
+        if 500 <= response.status < 600:
+            raise ServerError(str(response))
+        return response
+
+
+    def _stringify_query_parameter(self, value):
+        if isinstance(value, (list, tuple)):
+            return ','.join(value)
+        return str(value)
+
+
+    def get(self, **query_parameters):
+        string_parameters = dict((key, self._stringify_query_parameter(value))
+                                 for key, value in query_parameters.iteritems()
+                                 if value is not None)
+        response = self._request('GET', query_parameters=string_parameters)
+        assert response.status == 200
+        return self._read_representation(response.decoded_body())
+
+
+    def put(self):
+        response = self._request('PUT', encoded_body=self._representation())
+        assert response.status == 200
+        return self._read_representation(response.decoded_body())
+
+
+    def delete(self):
+        response = self._request('DELETE')
+        assert response.status == 204 # no content
+
+
+    def post(self, request_dict):
+        # request_dict may still have resources in it
+        request_dict = self._write_representation(request_dict)
+        response = self._request('POST', encoded_body=request_dict)
+        assert response.status == 201 # created
+        return self._read_representation({'href': response.headers['location']})