Implement basic functionality plus some extras
[pithos] / pithos / api / util.py
index ec8eb87..b7ecbbc 100644 (file)
-#\r
-# Copyright (c) 2011 Greek Research and Technology Network\r
-#\r
-\r
-from functools import wraps\r
-\r
-from time import time\r
-from wsgiref.handlers import format_date_time\r
-\r
-from django.conf import settings\r
-from django.http import HttpResponse\r
-\r
-from pithos.api.faults import Fault, BadRequest, ServiceUnavailable\r
-\r
-import datetime\r
-import logging\r
-\r
-logger = logging.getLogger(__name__)\r
-\r
-def format_meta_key(k):\r
-    """\r
-    Convert underscores to dashes and capitalize intra-dash strings.\r
-    """\r
-    return '-'.join([x.capitalize() for x in k.replace('_', '-').split('-')])\r
-\r
-def get_meta(request, prefix):\r
-    """\r
-    Get all prefix-* request headers in a dict. Reformat keys with format_meta_key().\r
-    """\r
-    prefix = 'HTTP_' + prefix.upper().replace('-', '_')\r
-    return dict([(format_meta_key(k[5:]), v) for k, v in request.META.iteritems() if k.startswith(prefix)])\r
-\r
-def get_range(request):\r
-    """\r
-    Parse a Range header from the request.\r
-    Either returns None, or an (offset, length) tuple.\r
-    If no offset is defined offset equals 0.\r
-    If no length is defined length is None.\r
-    """\r
-    \r
-    range = request.GET.get('range')\r
-    if not range:\r
-        return None\r
-    \r
-    range = range.replace(' ', '')\r
-    if not range.startswith('bytes='):\r
-        return None\r
-    \r
-    parts = range.split('-')\r
-    if len(parts) != 2:\r
-        return None\r
-    \r
-    offset, length = parts\r
-    if offset == '' and length == '':\r
-        return None\r
-    \r
-    if offset != '':\r
-        try:\r
-            offset = int(offset)\r
-        except ValueError:\r
-            return None\r
-    else:\r
-        offset = 0\r
-    \r
-    if length != '':\r
-        try:\r
-            length = int(length)\r
-        except ValueError:\r
-            return None\r
-    else:\r
-        length = None\r
-    \r
-    return (offset, length)\r
-\r
-def update_response_headers(request, response):\r
-    if request.serialization == 'xml':\r
-        response['Content-Type'] = 'application/xml; charset=UTF-8'\r
-    elif request.serialization == 'json':\r
-        response['Content-Type'] = 'application/json; charset=UTF-8'\r
-    else:\r
-        response['Content-Type'] = 'text/plain; charset=UTF-8'\r
-\r
-    if settings.TEST:\r
-        response['Date'] = format_date_time(time())\r
-\r
-def render_fault(request, fault):\r
-    response = HttpResponse(status = fault.code)\r
-    update_response_headers(request, response)\r
-    return response\r
-\r
-def request_serialization(request, format_allowed=False):\r
-    """\r
-    Return the serialization format requested.\r
-       \r
-    Valid formats are 'text' and 'json', 'xml' if `format_allowed` is True.\r
-    """\r
-    \r
-    if not format_allowed:\r
-        return 'text'\r
-    \r
-    format = request.GET.get('format')\r
-    if format == 'json':\r
-        return 'json'\r
-    elif format == 'xml':\r
-        return 'xml'\r
-    \r
-    for item in request.META.get('HTTP_ACCEPT', '').split(','):\r
-        accept, sep, rest = item.strip().partition(';')\r
-        if accept == 'text/plain':\r
-            return 'text'\r
-        elif accept == 'application/json':\r
-            return 'json'\r
-        elif accept == 'application/xml' or accept == 'text/xml':\r
-            return 'xml'\r
-    \r
-    return 'text'\r
-\r
-def api_method(http_method = None, format_allowed = False):\r
-    """\r
-    Decorator function for views that implement an API method.\r
-    """\r
-    \r
-    def decorator(func):\r
-        @wraps(func)\r
-        def wrapper(request, *args, **kwargs):\r
-            try:\r
-                if http_method and request.method != http_method:\r
-                    raise BadRequest('Method not allowed.')\r
-\r
-                # The args variable may contain up to (account, container, object).\r
-                if len(args) > 1 and len(args[1]) > 256:\r
-                    raise BadRequest('Container name too large.')\r
-                if len(args) > 2 and len(args[2]) > 1024:\r
-                    raise BadRequest('Object name too large.')\r
-                \r
-                # Fill in custom request variables.\r
-                request.serialization = request_serialization(request, format_allowed)\r
-                # TODO: Authenticate.\r
-                request.user = "test"\r
-                \r
-                response = func(request, *args, **kwargs)\r
-                update_response_headers(request, response)\r
-                return response\r
-            except Fault, fault:\r
-                return render_fault(request, fault)\r
-            except BaseException, e:\r
-                logger.exception('Unexpected error: %s' % e)\r
-                fault = ServiceUnavailable('Unexpected error')\r
-                return render_fault(request, fault)\r
-        return wrapper\r
-    return decorator\r
+from functools import wraps
+from time import time
+from traceback import format_exc
+from wsgiref.handlers import format_date_time
+
+from django.conf import settings
+from django.http import HttpResponse
+from django.utils.http import http_date
+
+from pithos.api.compat import parse_http_date_safe
+from pithos.api.faults import (Fault, NotModified, BadRequest, ItemNotFound, PreconditionFailed,
+                                ServiceUnavailable)
+from pithos.backends import backend
+
+import datetime
+import logging
+
+
+logger = logging.getLogger(__name__)
+
+
+def printable_meta_dict(d):
+    """Format a meta dictionary for printing out json/xml.
+    
+    Convert all keys to lower case and replace dashes to underscores.
+    Change 'modified' key from backend to 'last_modified' and format date.
+    """
+    if 'modified' in d:
+        d['last_modified'] = datetime.datetime.fromtimestamp(int(d['modified'])).isoformat()
+        del(d['modified'])
+    return dict([(k.lower().replace('-', '_'), v) for k, v in d.iteritems()])
+
+def format_meta_key(k):
+    """Convert underscores to dashes and capitalize intra-dash strings"""
+    return '-'.join([x.capitalize() for x in k.replace('_', '-').split('-')])
+
+def get_meta_prefix(request, prefix):
+    """Get all prefix-* request headers in a dict. Reformat keys with format_meta_key()"""
+    prefix = 'HTTP_' + prefix.upper().replace('-', '_')
+    return dict([(format_meta_key(k[5:]), v) for k, v in request.META.iteritems() if k.startswith(prefix)])
+
+def get_account_meta(request):
+    """Get metadata from an account request"""
+    meta = get_meta_prefix(request, 'X-Account-Meta-')    
+    return meta
+
+def put_account_meta(response, meta):
+    """Put metadata in an account response"""
+    response['X-Account-Container-Count'] = meta['count']
+    response['X-Account-Bytes-Used'] = meta['bytes']
+    if 'modified' in meta:
+        response['Last-Modified'] = http_date(int(meta['modified']))
+    for k in [x for x in meta.keys() if x.startswith('X-Account-Meta-')]:
+        response[k.encode('utf-8')] = meta[k].encode('utf-8')
+
+def get_container_meta(request):
+    """Get metadata from a container request"""
+    meta = get_meta_prefix(request, 'X-Container-Meta-')
+    return meta
+
+def put_container_meta(response, meta):
+    """Put metadata in a container response"""
+    response['X-Container-Object-Count'] = meta['count']
+    response['X-Container-Bytes-Used'] = meta['bytes']
+    if 'modified' in meta:
+        response['Last-Modified'] = http_date(int(meta['modified']))
+    for k in [x for x in meta.keys() if x.startswith('X-Container-Meta-')]:
+        response[k.encode('utf-8')] = meta[k].encode('utf-8')
+
+def get_object_meta(request):
+    """Get metadata from an object request"""
+    meta = get_meta_prefix(request, 'X-Object-Meta-')
+    if request.META.get('CONTENT_TYPE'):
+        meta['Content-Type'] = request.META['CONTENT_TYPE']
+    if request.META.get('HTTP_CONTENT_ENCODING'):
+        meta['Content-Encoding'] = request.META['HTTP_CONTENT_ENCODING']
+    if request.META.get('HTTP_X_OBJECT_MANIFEST'):
+        meta['X-Object-Manifest'] = request.META['HTTP_X_OBJECT_MANIFEST']
+    return meta
+
+def put_object_meta(response, meta):
+    """Put metadata in an object response"""
+    response['ETag'] = meta['hash']
+    response['Content-Length'] = meta['bytes']
+    response['Content-Type'] = meta.get('Content-Type', 'application/octet-stream')
+    response['Last-Modified'] = http_date(int(meta['modified']))
+    for k in [x for x in meta.keys() if x.startswith('X-Object-Meta-')]:
+        response[k.encode('utf-8')] = meta[k].encode('utf-8')
+    for k in ('Content-Encoding', 'X-Object-Manifest'):
+        if k in meta:
+            response[k] = meta[k]
+
+def validate_modification_preconditions(request, meta):
+    """Check that the modified timestamp conforms with the preconditions set"""
+    if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE')
+    if if_modified_since is not None:
+        if_modified_since = parse_http_date_safe(if_modified_since)
+    if if_modified_since is not None and 'modified' in meta and int(meta['modified']) <= if_modified_since:
+        raise NotModified('Object has not been modified')
+    
+    if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
+    if if_unmodified_since is not None:
+        if_unmodified_since = parse_http_date_safe(if_unmodified_since)
+    if if_unmodified_since is not None and 'modified' in meta and int(meta['modified']) > if_unmodified_since:
+        raise PreconditionFailed('Object has been modified')
+
+def copy_or_move_object(request, src_path, dest_path, move=False):
+    """Copy or move an object"""
+    if type(src_path) == str:
+        parts = src_path.split('/')
+        if len(parts) < 3 or parts[0] != '':
+            raise BadRequest('Invalid X-Copy-From or X-Move-From header')
+        src_container = parts[1]
+        src_name = '/'.join(parts[2:])
+    elif type(src_path) == tuple and len(src_path) == 2:
+        src_container, src_name = src_path
+    
+    if type(dest_path) == str:
+        parts = dest_path.split('/')
+        if len(parts) < 3 or parts[0] != '':
+            raise BadRequest('Invalid Destination header')
+        dest_container = parts[1]
+        dest_name = '/'.join(parts[2:])
+    elif type(dest_path) == tuple and len(dest_path) == 2:
+        dest_container, dest_name = dest_path
+
+    meta = get_object_meta(request)
+    try:
+        if move:
+            backend.move_object(request.user, src_container, src_name, dest_container, dest_name, meta)
+        else:
+            backend.copy_object(request.user, src_container, src_name, dest_container, dest_name, meta)
+    except NameError:
+        raise ItemNotFound('Container or object does not exist')
+
+def get_range(request):
+    """Parse a Range header from the request
+    
+    Either returns None, or an (offset, length) tuple.
+    If no length is defined length is None.
+    May return a negative offset (offset from the end).
+    """
+    range = request.META.get('HTTP_RANGE', '').replace(' ', '')
+    if not range.startswith('bytes='):
+        return None
+    
+    parts = range[6:].split('-')
+    if len(parts) != 2:
+        return None
+    
+    offset, upto = parts
+    if offset == '' and upto == '':
+        return None
+    if offset != '':
+        try:
+            offset = int(offset)
+        except ValueError:
+            return None
+        
+        if upto != '':
+            try:
+                upto = int(upto)
+            except ValueError:
+                return None
+        else:
+            return (offset, None)
+        
+        if offset > upto:
+            return None
+        return (offset, upto - offset + 1)
+    else:
+        try:
+            offset = -int(upto)
+        except ValueError:
+            return None
+        return (offset, None)
+
+def raw_input_socket(request):
+    """Return the socket for reading the rest of the request"""
+    server_software = request.META.get('SERVER_SOFTWARE')
+    if not server_software:
+        if 'wsgi.input' in request.environ:
+            return request.environ['wsgi.input']
+        raise ServiceUnavailable('Unknown server software')
+    if server_software.startswith('WSGIServer'):
+        return request.environ['wsgi.input']
+    elif server_software.startswith('mod_python'):
+        return request._req
+    raise ServiceUnavailable('Unknown server software')
+
+MAX_UPLOAD_SIZE = 10 * (1024 * 1024) # 10MB
+
+def socket_read_iterator(sock, length=-1, blocksize=4096):
+    """Return a maximum of blocksize data read from the socket in each iteration
+    
+    Read up to 'length'. If no 'length' is defined, will attempt a chunked read.
+    The maximum ammount of data read is controlled by MAX_UPLOAD_SIZE.
+    """
+    if length < 0: # Chunked transfers
+        while length < MAX_UPLOAD_SIZE:
+            chunk_length = sock.readline()
+            pos = chunk_length.find(';')
+            if pos >= 0:
+                chunk_length = chunk_length[:pos]
+            try:
+                chunk_length = int(chunk_length, 16)
+            except Exception, e:
+                raise BadRequest('Bad chunk size') # TODO: Change to something more appropriate.
+            if chunk_length == 0:
+                return
+            while chunk_length > 0:
+                data = sock.read(min(chunk_length, blocksize))
+                chunk_length -= len(data)
+                length += len(data)
+                yield data
+            data = sock.read(2) # CRLF
+        # TODO: Raise something to note that maximum size is reached.
+    else:
+        if length > MAX_UPLOAD_SIZE:
+            # TODO: Raise something to note that maximum size is reached.
+            pass
+        while length > 0:
+            data = sock.read(min(length, blocksize))
+            length -= len(data)
+            yield data
+
+def update_response_headers(request, response):
+    if request.serialization == 'xml':
+        response['Content-Type'] = 'application/xml; charset=UTF-8'
+    elif request.serialization == 'json':
+        response['Content-Type'] = 'application/json; charset=UTF-8'
+    else:
+        response['Content-Type'] = 'text/plain; charset=UTF-8'
+
+    if settings.TEST:
+        response['Date'] = format_date_time(time())
+
+def render_fault(request, fault):
+    if settings.DEBUG or settings.TEST:
+        fault.details = format_exc(fault)
+
+    request.serialization = 'text'
+    data = '\n'.join((fault.message, fault.details)) + '\n'
+    response = HttpResponse(data, status=fault.code)
+    update_response_headers(request, response)
+    return response
+
+def request_serialization(request, format_allowed=False):
+    """Return the serialization format requested
+    
+    Valid formats are 'text' and 'json', 'xml' if 'format_allowed' is True.
+    """
+    if not format_allowed:
+        return 'text'
+    
+    format = request.GET.get('format')
+    if format == 'json':
+        return 'json'
+    elif format == 'xml':
+        return 'xml'
+    
+    for item in request.META.get('HTTP_ACCEPT', '').split(','):
+        accept, sep, rest = item.strip().partition(';')
+        if accept == 'text/plain':
+            return 'text'
+        elif accept == 'application/json':
+            return 'json'
+        elif accept == 'application/xml' or accept == 'text/xml':
+            return 'xml'
+    
+    return 'text'
+
+def api_method(http_method=None, format_allowed=False):
+    """Decorator function for views that implement an API method"""
+    def decorator(func):
+        @wraps(func)
+        def wrapper(request, *args, **kwargs):
+            try:
+                if http_method and request.method != http_method:
+                    raise BadRequest('Method not allowed.')
+
+                # The args variable may contain up to (account, container, object).
+                if len(args) > 1 and len(args[1]) > 256:
+                    raise BadRequest('Container name too large.')
+                if len(args) > 2 and len(args[2]) > 1024:
+                    raise BadRequest('Object name too large.')
+                
+                # Fill in custom request variables.
+                request.serialization = request_serialization(request, format_allowed)
+                # TODO: Authenticate.
+                request.user = "test"
+                
+                response = func(request, *args, **kwargs)
+                update_response_headers(request, response)
+                return response
+            except Fault, fault:
+                return render_fault(request, fault)
+            except BaseException, e:
+                logger.exception('Unexpected error: %s' % e)
+                fault = ServiceUnavailable('Unexpected error')
+                return render_fault(request, fault)
+        return wrapper
+    return decorator