import cgi, datetime, re, time, urllib from django import http import django.core.exceptions from django.core import urlresolvers from django.utils import datastructures import json 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, request): assert self._permitted_methods # this request should be used for global environment info, like # constructing absolute URIs. it should not be used for query # parameters, because the request may not have been for this particular # resource. self._request = request # this dict will contain the applicable query parameters self._query_params = datastructures.MultiValueDict() @classmethod def dispatch_request(cls, request, *args, **kwargs): # handle a request directly try: try: instance = cls.from_uri_args(request, **kwargs) except django.core.exceptions.ObjectDoesNotExist, exc: raise http.Http404(exc) instance.read_query_parameters(request.GET) return instance.handle_request() except exceptions.RequestError, exc: return exc.response def handle_request(self): if self._request.method.upper() not in self._permitted_methods: return http.HttpResponseNotAllowed(self._permitted_methods) handler = getattr(self, self._request.method.lower()) return handler() # the handler methods below only need to be overridden if the resource # supports the method def get(self): """Handle a GET request. @returns an HttpResponse """ raise NotImplementedError def post(self): """Handle a POST request. @returns an HttpResponse """ raise NotImplementedError def put(self): """Handle a PUT request. @returns an HttpResponse """ raise NotImplementedError def delete(self): """Handle a DELETE request. @returns an HttpResponse """ raise NotImplementedError @classmethod def from_uri_args(cls, request, **kwargs): """Construct an instance from URI args. Default implementation for resources with no URI args. """ return cls(request) def _uri_args(self): """Return kwargs for a URI reference to this resource. Default implementation for resources with no URI args. """ return {} def _query_parameters_accepted(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 read_query_parameters(self, parameters): """Read relevant query parameters from a Django MultiValueDict.""" params_acccepted = set(param_name for param_name, _ in self._query_parameters_accepted()) for name, values in parameters.iterlists(): base_name = name.split(':', 1)[0] if base_name in params_acccepted: self._query_params.setlist(name, values) def set_query_parameters(self, **parameters): """Set query parameters programmatically.""" self._query_params.update(parameters) def href(self, query_params=None): """Return URI to this resource.""" kwargs = self._uri_args() path = urlresolvers.reverse(self.dispatch_request, kwargs=kwargs) full_query_params = datastructures.MultiValueDict(self._query_params) if query_params: full_query_params.update(query_params) if full_query_params: path += '?' + urllib.urlencode(full_query_params.lists(), doseq=True) return self._request.build_absolute_uri(path) def resolve_uri(self, uri): # check for absolute URIs match = re.match(r'(?Phttps?://[^/]+)(?P/.*)', uri) if match: # is this URI for a different host? my_root = self._request.build_absolute_uri('/') request_root = match.group('root') + '/' if my_root != request_root: # might support this in the future, but not now raise exceptions.BadRequest('Unable to resolve remote URI %s' % uri) uri = match.group('path') try: view_method, args, kwargs = urlresolvers.resolve(uri) except http.Http404: raise exceptions.BadRequest('Unable to resolve URI %s' % uri) resource_class = view_method.im_self # class owning this classmethod return resource_class.from_uri_args(self._request, **kwargs) def resolve_link(self, 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 self.resolve_uri(uri) def link(self, query_params=None): return {'href': self.href(query_params=query_params)} def _query_parameters_response(self): return dict((name, description) for name, description in self._query_parameters_accepted()) 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 = json.dumps(content) return http.HttpResponse(encoded_content, content_type=_JSON_CONTENT_TYPE) def _decoded_input(self): content_type = self._request.META.get('CONTENT_TYPE', _JSON_CONTENT_TYPE) raw_data = self._request.raw_post_data if content_type == _JSON_CONTENT_TYPE: try: raw_dict = json.loads(raw_data) except ValueError, exc: raise exceptions.BadRequest('Error decoding request body: ' '%s\n%r' % (exc, raw_data)) if not isinstance(raw_dict, dict): raise exceptions.BadRequest('Expected dict input, got %s: %r' % (type(raw_dict), raw_dict)) 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] = json.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): @classmethod def add_query_selectors(cls, query_processor): """Sbuclasses may override this to support querying.""" pass def short_representation(self): return self.link() def full_representation(self): return self.short_representation() def get(self): return self._basic_response(self.full_representation()) def put(self): try: self.update(self._decoded_input()) except model_logic.ValidationError, exc: raise exceptions.BadRequest('Invalid input: %s' % exc) return self._basic_response(self.full_representation()) def _delete_entry(self): raise NotImplementedError def delete(self): self._delete_entry() 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 InstanceEntry(Entry): class NullEntry(object): def link(self): return None def short_representation(self): return None _null_entry = NullEntry() _permitted_methods = ('GET', 'PUT', 'DELETE') model = None # subclasses must override this with a Django model class def __init__(self, request, instance): assert self.model is not None super(InstanceEntry, self).__init__(request) self.instance = instance self._is_prepared_for_full_representation = False @classmethod def from_optional_instance(cls, request, instance): if instance is None: return cls._null_entry return cls(request, instance) def _delete_entry(self): self.instance.delete() def full_representation(self): self.prepare_for_full_representation([self]) return super(InstanceEntry, self).full_representation() @classmethod def prepare_for_full_representation(cls, entries): """ Prepare the given list of entries to generate full representations. This method delegates to _do_prepare_for_full_representation(), which subclasses may override as necessary to do the actual processing. This method also marks the instance as prepared, so it's safe to call this multiple times with the same instance(s) without wasting work. """ not_prepared = [entry for entry in entries if not entry._is_prepared_for_full_representation] cls._do_prepare_for_full_representation([entry.instance for entry in not_prepared]) for entry in not_prepared: entry._is_prepared_for_full_representation = True @classmethod def _do_prepare_for_full_representation(cls, instances): """ Subclasses may override this to gather data as needed for full representations of the given model instances. Typically, this involves querying over related objects, and this method offers a chance to query for many instances at once, which can provide a great performance benefit. """ pass 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, request): super(Collection, self).__init__(request) 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 = query_lib.QueryProcessor() self.entry_class.add_query_selectors(self._query_processor) def _query_parameters_accepted(self): params = [('start_index', 'Index of first member to include'), ('items_per_page', 'Number of members to include'), ('full_representations', 'True to include full representations of members')] for selector in self._query_processor.selectors(): params.append((selector.name, selector.doc)) return params 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 _entry_from_instance(self, instance): return self.entry_class(self._request, instance) def _representation(self, entry_instances): entries = [self._entry_from_instance(instance) for instance in entry_instances] want_full_representation = self._read_bool_parameter( 'full_representations') if want_full_representation: self.entry_class.prepare_for_full_representation(entries) members = [] for entry in entries: if want_full_representation: rep = entry.full_representation() else: rep = entry.short_representation() members.append(rep) rep = self.link() rep.update({'members': members}) return rep def _read_bool_parameter(self, name): if name not in self._query_params: return False return (self._query_params[name].lower() == 'true') def _read_int_parameter(self, name, default): if name not in self._query_params: return default input_value = self._query_params[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, queryset): """Apply any query selectors passed as form variables.""" for parameter, values in self._query_params.lists(): if ':' in parameter: parameter, comparison_type = parameter.split(':', 1) else: comparison_type = None 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, comparison_type=comparison_type) return queryset def _filtered_queryset(self): return self._apply_form_query(self._fresh_queryset()) def get(self): queryset = self._filtered_queryset() items_per_page = self._read_int_parameter('items_per_page', self._DEFAULT_ITEMS_PER_PAGE) start_index = self._read_int_parameter('start_index', 0) page = queryset[start_index:(start_index + items_per_page)] rep = self._representation(page) rep.update({'total_results': len(queryset), 'start_index': start_index, 'items_per_page': items_per_page}) 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): input_dict = self._decoded_input() try: instance = self.entry_class.create_instance(input_dict, self) entry = self._entry_from_instance(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(Entry): _permitted_methods = ('GET', 'DELETE') # subclasses must override this with a dict mapping name to entry class related_classes = None def __init__(self, **kwargs): assert len(self.related_classes) == 2 self.entries = dict((name, kwargs[name]) for name in self.related_classes) for name in self.related_classes: # sanity check assert isinstance(self.entries[name], self.related_classes[name]) # just grab the request from one of the entries some_entry = self.entries.itervalues().next() super(Relationship, self).__init__(some_entry._request) @classmethod def from_uri_args(cls, request, **kwargs): # kwargs contains URI args for each entry entries = {} for name, entry_class in cls.related_classes.iteritems(): entries[name] = entry_class.from_uri_args(request, **kwargs) return cls(**entries) def _uri_args(self): kwargs = {} for name, entry in self.entries.iteritems(): kwargs.update(entry._uri_args()) return kwargs def short_representation(self): rep = self.link() for name, entry in self.entries.iteritems(): rep[name] = entry.short_representation() return rep @classmethod def _get_related_manager(cls, instance): """Get the related objects manager for the given instance. The instance must be one of the related classes. This method will return the related manager from that instance to instances of the other related class. """ this_model = type(instance) models = [entry_class.model for entry_class in cls.related_classes.values()] if isinstance(instance, models[0]): this_model, other_model = models else: other_model, this_model = models _, field = this_model.objects.determine_relationship(other_model) this_models_fields = (this_model._meta.fields + this_model._meta.many_to_many) if field in this_models_fields: manager_name = field.attname else: # related manager is on other_model, get name of reverse related # manager on this_model manager_name = field.related.get_accessor_name() return getattr(instance, manager_name) def _delete_entry(self): # choose order arbitrarily entry, other_entry = self.entries.itervalues() related_manager = self._get_related_manager(entry.instance) related_manager.remove(other_entry.instance) @classmethod def create_instance(cls, input_dict, containing_collection): other_name = containing_collection.unfixed_name cls._check_for_required_fields(input_dict, (other_name,)) entry = containing_collection.fixed_entry other_entry = containing_collection.resolve_link(input_dict[other_name]) related_manager = cls._get_related_manager(entry.instance) related_manager.add(other_entry.instance) return other_entry.instance def update(self, input_dict): pass class RelationshipCollection(Collection): def __init__(self, request=None, fixed_entry=None): if request is None: request = fixed_entry._request super(RelationshipCollection, self).__init__(request) assert issubclass(self.entry_class, Relationship) self.related_classes = self.entry_class.related_classes self.fixed_name = None self.fixed_entry = None self.unfixed_name = None self.related_manager = None if fixed_entry is not None: self._set_fixed_entry(fixed_entry) entry_uri_arg = self.fixed_entry._uri_args().values()[0] self._query_params[self.fixed_name] = entry_uri_arg def _set_fixed_entry(self, entry): """Set the fixed entry for this collection. The entry must be an instance of one of the related entry classes. This method must be called before a relationship is used. It gets called either from the constructor (when collections are instantiated from other resource handling code) or from read_query_parameters() (when a request is made directly for the collection. """ names = self.related_classes.keys() if isinstance(entry, self.related_classes[names[0]]): self.fixed_name, self.unfixed_name = names else: assert isinstance(entry, self.related_classes[names[1]]) self.unfixed_name, self.fixed_name = names self.fixed_entry = entry self.unfixed_class = self.related_classes[self.unfixed_name] self.related_manager = self.entry_class._get_related_manager( entry.instance) def _query_parameters_accepted(self): return [(name, 'Show relationships for this %s' % entry_class.__name__) for name, entry_class in self.related_classes.iteritems()] def _resolve_query_param(self, name, uri_arg): entry_class = self.related_classes[name] return entry_class.from_uri_args(self._request, uri_arg) def read_query_parameters(self, query_params): super(RelationshipCollection, self).read_query_parameters(query_params) if not self._query_params: raise exceptions.BadRequest( 'You must specify one of the parameters %s and %s' % tuple(self.related_classes.keys())) query_items = self._query_params.items() fixed_entry = self._resolve_query_param(*query_items[0]) self._set_fixed_entry(fixed_entry) if len(query_items) > 1: other_fixed_entry = self._resolve_query_param(*query_items[1]) self.related_manager = self.related_manager.filter( pk=other_fixed_entry.instance.id) def _entry_from_instance(self, instance): unfixed_entry = self.unfixed_class(self._request, instance) entries = {self.fixed_name: self.fixed_entry, self.unfixed_name: unfixed_entry} return self.entry_class(**entries) def _fresh_queryset(self): return self.related_manager.all()