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/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