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('&', '&').replace('"', '"')
+ .replace('<', '<').replace('>', '>'))
+
+
+ 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 = '&'
+ 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']})