blob: cf98acdd432595119463e4e3f48b3ceed45a6158 [file] [log] [blame]
showardf828c772010-01-25 21:49:42 +00001import cgi, datetime, time, urllib
2from django import http
3from django.core import exceptions, urlresolvers
4from django.utils import simplejson
5from autotest_lib.frontend.shared import exceptions, query_lib
6from autotest_lib.frontend.afe import model_logic
7
8
9_JSON_CONTENT_TYPE = 'application/json'
10
11
12def _resolve_class_path(class_path):
13 module_path, class_name = class_path.rsplit('.', 1)
14 module = __import__(module_path, {}, {}, [''])
15 return getattr(module, class_name)
16
17
18_NO_VALUE_SPECIFIED = object()
19
20class _InputDict(dict):
21 def get(self, key, default=_NO_VALUE_SPECIFIED):
22 return super(_InputDict, self).get(key, default)
23
24
25 @classmethod
26 def remove_unspecified_fields(cls, field_dict):
27 return dict((key, value) for key, value in field_dict.iteritems()
28 if value is not _NO_VALUE_SPECIFIED)
29
30
31class Resource(object):
32 _permitted_methods = None # subclasses must override this
33
34
35 def __init__(self):
36 assert self._permitted_methods
37
38
39 @classmethod
40 def dispatch_request(cls, request, *args, **kwargs):
41 # handle a request directly
42 try:
43 instance = cls.from_uri_args(*args, **kwargs)
44 except exceptions.ObjectDoesNotExist, exc:
45 raise http.Http404(exc)
46 return instance.handle_request(request)
47
48
49 def handle_request(self, request):
50 if request.method.upper() not in self._permitted_methods:
51 return http.HttpResponseNotAllowed(self._permitted_methods)
52
53 handler = getattr(self, request.method.lower())
54 try:
55 return handler(request)
56 except exceptions.RequestError, exc:
57 return exc.response
58
59
60 # the handler methods below only need to be overridden if the resource
61 # supports the method
62
63 def get(self, request):
64 """Handle a GET request.
65
66 @returns an HttpResponse
67 """
68 raise NotImplementedError
69
70
71 def post(self, request):
72 """Handle a POST request.
73
74 @returns an HttpResponse
75 """
76 raise NotImplementedError
77
78
79 def put(self, request):
80 """Handle a PUT request.
81
82 @returns an HttpResponse
83 """
84 raise NotImplementedError
85
86
87 def delete(self, request):
88 """Handle a DELETE request.
89
90 @returns an HttpResponse
91 """
92 raise NotImplementedError
93
94
95 @classmethod
96 def from_uri_args(cls):
97 """Construct an instance from URI args.
98
99 Default implementation for resources with no URI args.
100 """
101 return cls()
102
103
104 def _uri_args(self):
105 """Return (args, kwargs) for a URI reference to this resource.
106
107 Default implementation for resources with no URI args.
108 """
109 return (), {}
110
111
112 def _query_parameters(self):
113 """Return sequence of tuples (name, description) for query parameters.
114
115 Documents the available query parameters for GETting this resource.
116 Default implementation for resources with no parameters.
117 """
118 return ()
119
120
121 def href(self):
122 """Return URI to this resource."""
123 args, kwargs = self._uri_args()
124 return urlresolvers.reverse(self.dispatch_request, args=args,
125 kwargs=kwargs)
126
127
128 @classmethod
129 def resolve_uri(cls, uri):
130 view_method, args, kwargs = urlresolvers.resolve(uri)
131 resource_class = view_method.im_self # class owning this classmethod
132 return resource_class.from_uri_args(*args, **kwargs)
133
134
135 @classmethod
136 def resolve_link(cls, link):
137 if isinstance(link, dict):
138 uri = link['href']
139 elif isinstance(link, basestring):
140 uri = link
141 else:
142 raise exceptions.BadRequest('Unable to understand link %s' % link)
143 return cls.resolve_uri(uri)
144
145
146 def link(self):
147 return {'href': self.href()}
148
149
150 def _query_parameters_response(self):
151 return dict((name, description)
152 for name, description in self._query_parameters())
153
154
155 def _basic_response(self, content):
156 """Construct and return a simple 200 response."""
157 assert isinstance(content, dict)
158 query_parameters = self._query_parameters_response()
159 if query_parameters:
160 content['query_parameters'] = query_parameters
161 encoded_content = simplejson.dumps(content)
162 return http.HttpResponse(encoded_content,
163 content_type=_JSON_CONTENT_TYPE)
164
165
166 @classmethod
167 def _decoded_input(cls, request):
168 content_type = request.META.get('CONTENT_TYPE', _JSON_CONTENT_TYPE)
169 raw_data = request.raw_post_data
170 if content_type == _JSON_CONTENT_TYPE:
171 try:
172 raw_dict = simplejson.loads(raw_data)
173 except ValueError, exc:
174 raise exceptions.BadRequest('Error decoding request body: '
175 '%s\n%r' % (exc, raw_data))
176 elif content_type == 'application/x-www-form-urlencoded':
177 cgi_dict = cgi.parse_qs(raw_data) # django won't do this for PUT
178 raw_dict = {}
179 for key, values in cgi_dict.items():
180 value = values[-1] # take last value if multiple were given
181 try:
182 # attempt to parse numbers, booleans and nulls
183 raw_dict[key] = simplejson.loads(value)
184 except ValueError:
185 # otherwise, leave it as a string
186 raw_dict[key] = value
187 else:
188 raise exceptions.RequestError(415, 'Unsupported media type: %s'
189 % content_type)
190
191 return _InputDict(raw_dict)
192
193
194 def _format_datetime(self, date_time):
195 """Return ISO 8601 string for the given datetime"""
196 if date_time is None:
197 return None
198 timezone_hrs = time.timezone / 60 / 60 # convert seconds to hours
199 if timezone_hrs >= 0:
200 timezone_join = '+'
201 else:
202 timezone_join = '' # minus sign comes from number itself
203 timezone_spec = '%s%s:00' % (timezone_join, timezone_hrs)
204 return date_time.strftime('%Y-%m-%dT%H:%M:%S') + timezone_spec
205
206
207 @classmethod
208 def _check_for_required_fields(cls, input_dict, fields):
209 assert isinstance(fields, (list, tuple)), fields
210 missing_fields = ', '.join(field for field in fields
211 if field not in input_dict)
212 if missing_fields:
213 raise exceptions.BadRequest('Missing input: ' + missing_fields)
214
215
216class Entry(Resource):
217 class NullEntry(object):
218 def link(self):
219 return None
220
221
222 def short_representation(self):
223 return None
224
225 _null_entry = NullEntry()
226
227
228 _permitted_methods = ('GET', 'PUT', 'DELETE')
229
230
231 # sublcasses must define this class to support querying
232 QueryProcessor = query_lib.BaseQueryProcessor
233
234
235 def __init__(self, instance):
236 super(Entry, self).__init__()
237 self.instance = instance
238
239
240 @classmethod
241 def from_optional_instance(cls, instance):
242 if instance is None:
243 return cls._null_entry
244 return cls(instance)
245
246
247 def short_representation(self):
248 return self.link()
249
250
251 def full_representation(self):
252 return self.short_representation()
253
254
255 def get(self, request):
256 return self._basic_response(self.full_representation())
257
258
259 def put(self, request):
260 try:
261 self.update(self._decoded_input(request))
262 except model_logic.ValidationError, exc:
263 raise exceptions.BadRequest('Invalid input: %s' % exc)
264 return self._basic_response(self.full_representation())
265
266
267 def delete(self, request):
268 self.instance.delete()
269 return http.HttpResponse(status=204) # No content
270
271
272 def create_instance(self, input_dict, containing_collection):
273 raise NotImplementedError
274
275
276 def update(self, input_dict):
277 raise NotImplementedError
278
279
280class Collection(Resource):
281 _DEFAULT_ITEMS_PER_PAGE = 50
282
283 _permitted_methods=('GET', 'POST')
284
285 # subclasses must override these
286 queryset = None # or override _fresh_queryset() directly
287 entry_class = None
288
289
290 def __init__(self):
291 super(Collection, self).__init__()
292 assert self.entry_class is not None
293 if isinstance(self.entry_class, basestring):
294 type(self).entry_class = _resolve_class_path(self.entry_class)
295
296 self._query_processor = self.entry_class.QueryProcessor()
297
298
299 def _fresh_queryset(self):
300 assert self.queryset is not None
301 # always copy the queryset before using it to avoid caching
302 return self.queryset.all()
303
304
305 def _representation(self, entry_instances):
306 members = []
307 for instance in entry_instances:
308 entry = self.entry_class(instance)
309 members.append(entry.short_representation())
310
311 rep = self.link()
312 rep.update({'members': members})
313 return rep
314
315
316 def _read_int_parameter(self, query_dict, name, default):
317 if name not in query_dict:
318 return default
319 input_value = query_dict[name]
320 try:
321 return int(input_value)
322 except ValueError:
323 raise exceptions.BadRequest('Invalid non-numeric value for %s: %r'
324 % (name, input_value))
325
326
327 def _apply_form_query(self, request, queryset):
328 """Apply any query selectors passed as form variables."""
329 for parameter, values in request.GET.lists():
330 if not self._query_processor.has_selector(parameter):
331 continue
332 for value in values: # forms keys can have multiple values
333 queryset = self._query_processor.apply_selector(queryset,
334 parameter,
335 value)
336 return queryset
337
338
339 def _filtered_queryset(self, request):
340 return self._apply_form_query(request, self._fresh_queryset())
341
342
343 def get(self, request):
344 queryset = self._filtered_queryset(request)
345
346 items_per_page = self._read_int_parameter(request.GET, 'items_per_page',
347 self._DEFAULT_ITEMS_PER_PAGE)
348 start_index = self._read_int_parameter(request.GET, 'start_index', 0)
349 page = queryset[start_index:(start_index + items_per_page)]
350
351 rep = self._representation(page)
352 selector_dict = dict((selector.name, selector.doc)
353 for selector
354 in self.entry_class.QueryProcessor.selectors())
355 rep.update({'total_results': len(queryset),
356 'start_index': start_index,
357 'items_per_page': items_per_page,
358 'filtering_selectors': selector_dict})
359 return self._basic_response(rep)
360
361
362 def full_representation(self):
363 # careful, this rep can be huge for large collections
364 return self._representation(self._fresh_queryset())
365
366
367 def post(self, request):
368 input_dict = self._decoded_input(request)
369 try:
370 instance = self.entry_class.create_instance(input_dict, self)
371 entry = self.entry_class(instance)
372 entry.update(input_dict)
373 except model_logic.ValidationError, exc:
374 raise exceptions.BadRequest('Invalid input: %s' % exc)
375 # RFC 2616 specifies that we provide the new URI in both the Location
376 # header and the body
377 response = http.HttpResponse(status=201, # Created
378 content=entry.href())
379 response['Location'] = entry.href()
380 return response
381
382
383class Relationship(Collection):
384 _permitted_methods=('GET', 'PUT')
385
386 base_entry_class = None # subclasses must override this
387
388
389 def __init__(self, base_entry):
390 assert self.base_entry_class
391 if isinstance(self.base_entry_class, basestring):
392 type(self).base_entry_class = _resolve_class_path(
393 self.base_entry_class)
394 assert isinstance(base_entry, self.base_entry_class)
395 self.base_entry = base_entry
396 super(Relationship, self).__init__()
397
398
399 def _fresh_queryset(self):
400 """Return a QuerySet for this relationship using self.base_entry."""
401 raise NotImplementedError
402
403
404 @classmethod
405 def from_uri_args(cls, *args, **kwargs):
406 base_entry = cls.base_entry_class.from_uri_args(*args, **kwargs)
407 return cls(base_entry)
408
409
410 def _uri_args(self):
411 return self.base_entry._uri_args()
412
413
414 @classmethod
415 def _read_hrefs(cls, links):
416 return [link['href'] for link in links]
417
418
419 @classmethod
420 def _input_collection_hrefs(cls, input_data):
421 """Get the members of a user-provided collection.
422
423 Tries to be flexible about formats accepted from the user.
424 @returns a list of hrefs
425 """
426 if isinstance(input_data, dict) and 'members' in input_data:
427 # this mirrors the output representation for collections
428 # guard against accidental truncation of the relationship due to
429 # paging
430 is_partial_collection = ('total_results' in input_data
431 and 'items_per_page' in input_data
432 and input_data['total_results'] >
433 input_data['items_per_page'])
434 if is_partial_collection:
435 raise exceptions.BadRequest('You must retreive the full '
436 'collection to perform updates')
437
438 return cls._read_hrefs(input_data['members'])
439 if isinstance(input_data, list):
440 if not input_data:
441 return input_data
442 if isinstance(input_data[0], dict):
443 # assume it's a list of links
444 return cls._read_hrefs(input_data)
445 if isinstance(input_data[0], basestring):
446 # assume it's a list of hrefs
447 return input_data
448 raise exceptions.BadRequest('Cannot understand collection in input: %r'
449 % input_data)
450
451
452 def put(self, request):
453 input_data = self._decoded_input(request)
454 self.update(input_data)
455 return self.get(request)
456
457
458 def update(self, input_data):
459 hrefs = self._input_collection_hrefs(input_data)
460 instances = [self.entry_class.resolve_uri(href).instance
461 for href in hrefs]
462 self._update_relationship(instances)
463
464
465 def _update_relationship(self, related_instances):
466 raise NotImplementedError