Provide metadata functions for tags and trash support. Restructure backend to work...
[pithos] / pithos / api / util.py
1 from functools import wraps
2 from time import time
3 from traceback import format_exc
4 from wsgiref.handlers import format_date_time
5
6 from django.conf import settings
7 from django.http import HttpResponse
8 from django.utils.http import http_date, parse_etags
9
10 from pithos.api.compat import parse_http_date_safe
11 from pithos.api.faults import (Fault, NotModified, BadRequest, ItemNotFound, LengthRequired,
12                                 PreconditionFailed, ServiceUnavailable)
13 from pithos.backends import backend
14
15 import datetime
16 import logging
17 import re
18
19
20 logger = logging.getLogger(__name__)
21
22
23 def printable_meta_dict(d):
24     """Format a meta dictionary for printing out json/xml.
25     
26     Convert all keys to lower case and replace dashes to underscores.
27     Change 'modified' key from backend to 'last_modified' and format date.
28     """
29     if 'modified' in d:
30         d['last_modified'] = datetime.datetime.fromtimestamp(int(d['modified'])).isoformat()
31         del(d['modified'])
32     return dict([(k.lower().replace('-', '_'), v) for k, v in d.iteritems()])
33
34 def format_meta_key(k):
35     """Convert underscores to dashes and capitalize intra-dash strings"""
36     return '-'.join([x.capitalize() for x in k.replace('_', '-').split('-')])
37
38 def get_meta_prefix(request, prefix):
39     """Get all prefix-* request headers in a dict. Reformat keys with format_meta_key()"""
40     prefix = 'HTTP_' + prefix.upper().replace('-', '_')
41     return dict([(format_meta_key(k[5:]), v) for k, v in request.META.iteritems() if k.startswith(prefix)])
42
43 def get_account_meta(request):
44     """Get metadata from an account request"""
45     meta = get_meta_prefix(request, 'X-Account-Meta-')    
46     return meta
47
48 def put_account_meta(response, meta):
49     """Put metadata in an account response"""
50     response['X-Account-Container-Count'] = meta['count']
51     response['X-Account-Bytes-Used'] = meta['bytes']
52     if 'modified' in meta:
53         response['Last-Modified'] = http_date(int(meta['modified']))
54     for k in [x for x in meta.keys() if x.startswith('X-Account-Meta-')]:
55         response[k.encode('utf-8')] = meta[k].encode('utf-8')
56
57 def get_container_meta(request):
58     """Get metadata from a container request"""
59     meta = get_meta_prefix(request, 'X-Container-Meta-')
60     return meta
61
62 def put_container_meta(response, meta):
63     """Put metadata in a container response"""
64     response['X-Container-Object-Count'] = meta['count']
65     response['X-Container-Bytes-Used'] = meta['bytes']
66     if 'modified' in meta:
67         response['Last-Modified'] = http_date(int(meta['modified']))
68     for k in [x for x in meta.keys() if x.startswith('X-Container-Meta-')]:
69         response[k.encode('utf-8')] = meta[k].encode('utf-8')
70     response['X-Container-Object-Meta'] = [x[14:] for x in meta['object_meta'] if x.startswith('X-Object-Meta-')]
71
72 def get_object_meta(request):
73     """Get metadata from an object request"""
74     meta = get_meta_prefix(request, 'X-Object-Meta-')
75     if request.META.get('CONTENT_TYPE'):
76         meta['Content-Type'] = request.META['CONTENT_TYPE']
77     if request.META.get('HTTP_CONTENT_ENCODING'):
78         meta['Content-Encoding'] = request.META['HTTP_CONTENT_ENCODING']
79     if request.META.get('HTTP_CONTENT_DISPOSITION'):
80         meta['Content-Disposition'] = request.META['HTTP_CONTENT_DISPOSITION']
81     if request.META.get('HTTP_X_OBJECT_MANIFEST'):
82         meta['X-Object-Manifest'] = request.META['HTTP_X_OBJECT_MANIFEST']
83     return meta
84
85 def put_object_meta(response, meta):
86     """Put metadata in an object response"""
87     response['ETag'] = meta['hash']
88     response['Content-Length'] = meta['bytes']
89     response['Content-Type'] = meta.get('Content-Type', 'application/octet-stream')
90     response['Last-Modified'] = http_date(int(meta['modified']))
91     for k in [x for x in meta.keys() if x.startswith('X-Object-Meta-')]:
92         response[k.encode('utf-8')] = meta[k].encode('utf-8')
93     for k in ('Content-Encoding', 'Content-Disposition', 'X-Object-Manifest'):
94         if k in meta:
95             response[k] = meta[k]
96
97 def validate_modification_preconditions(request, meta):
98     """Check that the modified timestamp conforms with the preconditions set"""
99     if 'modified' not in meta:
100         return # TODO: Always return?
101     
102     if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE')
103     if if_modified_since is not None:
104         if_modified_since = parse_http_date_safe(if_modified_since)
105     if if_modified_since is not None and int(meta['modified']) <= if_modified_since:
106         raise NotModified('Object has not been modified')
107     
108     if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
109     if if_unmodified_since is not None:
110         if_unmodified_since = parse_http_date_safe(if_unmodified_since)
111     if if_unmodified_since is not None and int(meta['modified']) > if_unmodified_since:
112         raise PreconditionFailed('Object has been modified')
113
114 def validate_matching_preconditions(request, meta):
115     """Check that the ETag conforms with the preconditions set"""
116     if 'hash' not in meta:
117         return # TODO: Always return?
118     
119     if_match = request.META.get('HTTP_IF_MATCH')
120     if if_match is not None and if_match != '*':
121         if meta['hash'] not in [x.lower() for x in parse_etags(if_match)]:
122             raise PreconditionFailed('Object Etag does not match')
123     
124     if_none_match = request.META.get('HTTP_IF_NONE_MATCH')
125     if if_none_match is not None:
126         if if_none_match == '*' or meta['hash'] in [x.lower() for x in parse_etags(if_none_match)]:
127             raise NotModified('Object Etag matches')
128
129 def copy_or_move_object(request, src_path, dest_path, move=False):
130     """Copy or move an object"""
131     if type(src_path) == str:
132         parts = src_path.split('/')
133         if len(parts) < 3 or parts[0] != '':
134             raise BadRequest('Invalid X-Copy-From or X-Move-From header')
135         src_container = parts[1]
136         src_name = '/'.join(parts[2:])
137     elif type(src_path) == tuple and len(src_path) == 2:
138         src_container, src_name = src_path
139     if type(dest_path) == str:
140         parts = dest_path.split('/')
141         if len(parts) < 3 or parts[0] != '':
142             raise BadRequest('Invalid Destination header')
143         dest_container = parts[1]
144         dest_name = '/'.join(parts[2:])
145     elif type(dest_path) == tuple and len(dest_path) == 2:
146         dest_container, dest_name = dest_path
147     
148     meta = get_object_meta(request)
149     # Keep previous values of 'Content-Type' (if a new one is absent) and 'hash'.
150     try:
151         src_meta = backend.get_object_meta(request.user, src_container, src_name)
152     except NameError:
153         raise ItemNotFound('Container or object does not exist')
154     if 'Content-Type' in meta and 'Content-Type' in src_meta:
155         del(src_meta['Content-Type'])
156     for k in ('Content-Type', 'hash'):
157         if k in src_meta:
158             meta[k] = src_meta[k]
159     
160     try:
161         if move:
162             backend.move_object(request.user, src_container, src_name, dest_container, dest_name, meta, replace_meta=True)
163         else:
164             backend.copy_object(request.user, src_container, src_name, dest_container, dest_name, meta, replace_meta=True)
165     except NameError:
166         raise ItemNotFound('Container or object does not exist')
167
168 def get_content_length(request):
169     content_length = request.META.get('CONTENT_LENGTH')
170     if not content_length:
171         raise LengthRequired('Missing Content-Length header')
172     try:
173         content_length = int(content_length)
174         if content_length < 0:
175             raise ValueError
176     except ValueError:
177         raise BadRequest('Invalid Content-Length header')
178     return content_length
179
180 def get_range(request, size):
181     """Parse a Range header from the request
182     
183     Either returns None, when the header is not existent or should be ignored,
184     or a list of (offset, length) tuples - should be further checked.
185     """
186     ranges = request.META.get('HTTP_RANGE', '').replace(' ', '')
187     if not ranges.startswith('bytes='):
188         return None
189     
190     ret = []
191     for r in (x.strip() for x in ranges[6:].split(',')):
192         p = re.compile('^(?P<offset>\d*)-(?P<upto>\d*)$')
193         m = p.match(r)
194         if not m:
195             return None
196         offset = m.group('offset')
197         upto = m.group('upto')
198         if offset == '' and upto == '':
199             return None
200         
201         if offset != '':
202             offset = int(offset)
203             if upto != '':
204                 upto = int(upto)
205                 if offset > upto:
206                     return None
207                 ret.append((offset, upto - offset + 1))
208             else:
209                 ret.append((offset, size - offset))
210         else:
211             length = int(upto)
212             ret.append((size - length, length))
213     
214     return ret
215
216 def get_content_range(request):
217     """Parse a Content-Range header from the request
218     
219     Either returns None, when the header is not existent or should be ignored,
220     or an (offset, length, total) tuple - check as length, total may be None.
221     Returns (None, None, None) if the provided range is '*/*'.
222     """
223     
224     ranges = request.META.get('HTTP_CONTENT_RANGE', '')
225     if not ranges:
226         return None
227     
228     p = re.compile('^bytes (?P<offset>\d+)-(?P<upto>\d*)/(?P<total>(\d+|\*))$')
229     m = p.match(ranges)
230     if not m:
231         if ranges == 'bytes */*':
232             return (None, None, None)
233         return None
234     offset = int(m.group('offset'))
235     upto = m.group('upto')
236     total = m.group('total')
237     if upto != '':
238         upto = int(upto)
239     else:
240         upto = None
241     if total != '*':
242         total = int(total)
243     else:
244         total = None
245     if (upto and offset > upto) or \
246         (total and offset >= total) or \
247         (total and upto and upto >= total):
248         return None
249     
250     if not upto:
251         length = None
252     else:
253         length = upto - offset + 1
254     return (offset, length, total)
255
256 def raw_input_socket(request):
257     """Return the socket for reading the rest of the request"""
258     server_software = request.META.get('SERVER_SOFTWARE')
259     if not server_software:
260         if 'wsgi.input' in request.environ:
261             return request.environ['wsgi.input']
262         raise ServiceUnavailable('Unknown server software')
263     if server_software.startswith('WSGIServer'):
264         return request.environ['wsgi.input']
265     elif server_software.startswith('mod_python'):
266         return request._req
267     raise ServiceUnavailable('Unknown server software')
268
269 MAX_UPLOAD_SIZE = 10 * (1024 * 1024) # 10MB
270
271 def socket_read_iterator(sock, length=0, blocksize=4096):
272     """Return a maximum of blocksize data read from the socket in each iteration
273     
274     Read up to 'length'. If 'length' is negative, will attempt a chunked read.
275     The maximum ammount of data read is controlled by MAX_UPLOAD_SIZE.
276     """
277     if length < 0: # Chunked transfers
278         data = ''
279         while length < MAX_UPLOAD_SIZE:
280             # Get chunk size.
281             if hasattr(sock, 'readline'):
282                 chunk_length = sock.readline()
283             else:
284                 chunk_length = ''
285                 while chunk_length[-1:] != '\n':
286                     chunk_length += sock.read(1)
287                 chunk_length.strip()
288             pos = chunk_length.find(';')
289             if pos >= 0:
290                 chunk_length = chunk_length[:pos]
291             try:
292                 chunk_length = int(chunk_length, 16)
293             except Exception, e:
294                 raise BadRequest('Bad chunk size') # TODO: Change to something more appropriate.
295             # Check if done.
296             if chunk_length == 0:
297                 if len(data) > 0:
298                     yield data
299                 return
300             # Get the actual data.
301             while chunk_length > 0:
302                 chunk = sock.read(min(chunk_length, blocksize))
303                 chunk_length -= len(chunk)
304                 length += len(chunk)
305                 data += chunk
306                 if len(data) >= blocksize:
307                     ret = data[:blocksize]
308                     data = data[blocksize:]
309                     yield ret
310             sock.read(2) # CRLF
311         # TODO: Raise something to note that maximum size is reached.
312     else:
313         if length > MAX_UPLOAD_SIZE:
314             # TODO: Raise something to note that maximum size is reached.
315             pass
316         while length > 0:
317             data = sock.read(min(length, blocksize))
318             length -= len(data)
319             yield data
320
321 class ObjectWrapper(object):
322     """Return the object's data block-per-block in each iteration
323     
324     Read from the object using the offset and length provided in each entry of the range list.
325     """
326     
327     def __init__(self, v_account, v_container, v_object, ranges, size, hashmap, boundary):
328         self.v_account = v_account
329         self.v_container = v_container
330         self.v_object = v_object
331         self.ranges = ranges
332         self.size = size
333         self.hashmap = hashmap
334         self.boundary = boundary
335         
336         self.block_index = -1
337         self.block = ''
338         
339         self.range_index = -1
340         self.offset, self.length = self.ranges[0]
341     
342     def __iter__(self):
343         return self
344     
345     def part_iterator(self):
346         if self.length > 0:
347             # Get the block for the current offset.
348             bi = int(self.offset / backend.block_size)
349             if self.block_index != bi:
350                 try:
351                     self.block = backend.get_block(self.hashmap[bi])
352                 except NameError:
353                     raise ItemNotFound('Block does not exist')
354                 self.block_index = bi
355             # Get the data from the block.
356             bo = self.offset % backend.block_size
357             bl = min(self.length, backend.block_size - bo)
358             data = self.block[bo:bo + bl]
359             self.offset += bl
360             self.length -= bl
361             return data
362         else:
363             raise StopIteration
364     
365     def next(self):
366         if len(self.ranges) == 1:
367             return self.part_iterator()
368         if self.range_index == len(self.ranges):
369             raise StopIteration
370         try:
371             if self.range_index == -1:
372                 raise StopIteration
373             return self.part_iterator()
374         except StopIteration:
375             self.range_index += 1
376             out = []
377             if self.range_index < len(self.ranges):
378                 # Part header.
379                 self.offset, self.length = self.ranges[self.range_index]
380                 if self.range_index > 0:
381                     out.append('')
382                 out.append('--' + self.boundary)
383                 out.append('Content-Range: bytes %d-%d/%d' % (self.offset, self.offset + self.length - 1, self.size))
384                 out.append('Content-Transfer-Encoding: binary')
385                 out.append('')
386                 out.append('')
387                 return '\r\n'.join(out)
388             else:
389                 # Footer.
390                 out.append('')
391                 out.append('--' + self.boundary + '--')
392                 out.append('')
393                 return '\r\n'.join(out)
394
395 def update_response_headers(request, response):
396     if request.serialization == 'xml':
397         response['Content-Type'] = 'application/xml; charset=UTF-8'
398     elif request.serialization == 'json':
399         response['Content-Type'] = 'application/json; charset=UTF-8'
400     elif not response['Content-Type']:
401         response['Content-Type'] = 'text/plain; charset=UTF-8'
402
403     if settings.TEST:
404         response['Date'] = format_date_time(time())
405
406 def render_fault(request, fault):
407     if settings.DEBUG or settings.TEST:
408         fault.details = format_exc(fault)
409
410     request.serialization = 'text'
411     data = '\n'.join((fault.message, fault.details)) + '\n'
412     response = HttpResponse(data, status=fault.code)
413     update_response_headers(request, response)
414     return response
415
416 def request_serialization(request, format_allowed=False):
417     """Return the serialization format requested
418     
419     Valid formats are 'text' and 'json', 'xml' if 'format_allowed' is True.
420     """
421     if not format_allowed:
422         return 'text'
423     
424     format = request.GET.get('format')
425     if format == 'json':
426         return 'json'
427     elif format == 'xml':
428         return 'xml'
429     
430     for item in request.META.get('HTTP_ACCEPT', '').split(','):
431         accept, sep, rest = item.strip().partition(';')
432         if accept == 'application/json':
433             return 'json'
434         elif accept == 'application/xml' or accept == 'text/xml':
435             return 'xml'
436     
437     return 'text'
438
439 def api_method(http_method=None, format_allowed=False):
440     """Decorator function for views that implement an API method"""
441     def decorator(func):
442         @wraps(func)
443         def wrapper(request, *args, **kwargs):
444             try:
445                 if http_method and request.method != http_method:
446                     raise BadRequest('Method not allowed.')
447
448                 # The args variable may contain up to (account, container, object).
449                 if len(args) > 1 and len(args[1]) > 256:
450                     raise BadRequest('Container name too large.')
451                 if len(args) > 2 and len(args[2]) > 1024:
452                     raise BadRequest('Object name too large.')
453                 
454                 # Fill in custom request variables.
455                 request.serialization = request_serialization(request, format_allowed)
456                 # TODO: Authenticate.
457                 request.user = "test"
458                 
459                 response = func(request, *args, **kwargs)
460                 update_response_headers(request, response)
461                 return response
462             except Fault, fault:
463                 return render_fault(request, fault)
464             except BaseException, e:
465                 logger.exception('Unexpected error: %s' % e)
466                 fault = ServiceUnavailable('Unexpected error')
467                 return render_fault(request, fault)
468         return wrapper
469     return decorator