blob: dfb33c3b8f233020df6723d064f81a806e1664eb [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Handler library for Blobstore API.
Contains handlers to help with uploading and downloading blobs.
Public Classes:
BlobstoreDownloadHandler: Has helper method for easily sending blobs
to client.
BlobstoreUploadHandler: Handler for receiving upload notification requests.
Public Exceptions (indentation indications class hierarchy):
Error: Base class for service handler errors.
RangeFormatError: Raised when Range header has invalid format.
UnsupportedRangeFormatError: Raised when range header has valid format
but a particular feature or unit type is not supported.
"""
import cgi
import collections
import re
from google.appengine.ext import blobstore
from google.appengine.ext import webapp
__all__ = [
'Error',
'RangeFormatError',
'UnsupportedRangeFormatError',
'BlobstoreDownloadHandler',
'BlobstoreUploadHandler',
]
_CONTENT_DISPOSITION_FORMAT = 'attachment; filename="%s"'
_SEND_BLOB_PARAMETERS = frozenset(['use_range'])
_RANGE_NUMERIC_FORMAT = r'([0-9]*)-([0-9]*)'
_RANGE_FORMAT = r'([a-zA-Z]+)=%s' % _RANGE_NUMERIC_FORMAT
_RANGE_FORMAT_REGEX = re.compile('^%s$' % _RANGE_FORMAT)
_UNSUPPORTED_RANGE_FORMAT_REGEX = re.compile(
'^%s(?:,%s)+$' % (_RANGE_FORMAT, _RANGE_NUMERIC_FORMAT))
_BYTES_UNIT = 'bytes'
class Error(Exception):
"""Base class for all errors in blobstore handlers module."""
if not hasattr(webapp, 'Error'):
class RangeFormatError(Error):
"""Raised when Range header incorrectly formatted."""
else:
class RangeFormatError(webapp.Error):
"""Raised when Range header incorrectly formatted."""
class UnsupportedRangeFormatError(RangeFormatError):
"""Raised when Range format is correct, but not supported."""
def _serialize_range(start, end):
"""Return a string suitable for use as a value in a Range header.
Args:
start: The start of the bytes range e.g. 50.
end: The end of the bytes range e.g. 100. This value is inclusive and may
be None if the end of the range is not specified.
Returns:
Returns a string (e.g. "bytes=50-100") that represents a serialized Range
header value.
"""
if start < 0:
range_str = '%d' % start
elif end is None:
range_str = '%d-' % start
else:
range_str = '%d-%d' % (start, end)
return 'bytes=%s' % range_str
def _parse_range_value(range_value):
"""Parses a single range value from a Range header.
Parses strings of the form "0-0", "0-", "0" and "-1" into (start, end) tuples,
respectively, (0, 0), (0, None), (0, None), (-1, None).
Args:
range_value: A str containing a single range of a Range header.
Returns:
A tuple containing (start, end) where end is None if the range only has a
start value.
Raises:
ValueError: If range_value is not a valid range.
"""
end = None
if range_value.startswith('-'):
start = int(range_value)
if start == 0:
raise ValueError('-0 is not a valid range.')
else:
split_range = range_value.split('-', 1)
start = int(split_range[0])
if len(split_range) > 1 and split_range[1].strip():
end = int(split_range[1])
if start > end:
raise ValueError('start must be <= end.')
return (start, end)
def _parse_bytes(range_header):
"""Parses a full HTTP Range header.
Args:
range_header: The str value of the Range header.
Returns:
A tuple (units, parsed_ranges) where:
units: A str containing the units of the Range header, e.g. "bytes".
parsed_ranges: A list of (start, end) tuples in the form that
_parsed_range_value returns.
"""
try:
parsed_ranges = []
units, ranges = range_header.split('=', 1)
for range_value in ranges.split(','):
range_value = range_value.strip()
if range_value:
parsed_ranges.append(_parse_range_value(range_value))
if not parsed_ranges:
return None
return units, parsed_ranges
except ValueError:
return None
def _check_ranges(start, end, use_range_set, use_range, range_header):
"""Set the range header.
Args:
start: As passed in from send_blob.
end: As passed in from send_blob.
use_range_set: Use range was explcilty set during call to send_blob.
use_range: As passed in from send blob.
range_header: Range header as received in HTTP request.
Returns:
Range header appropriate for placing in blobstore.BLOB_RANGE_HEADER.
Raises:
ValueError if parameters are incorrect. This happens:
- start > end.
- start < 0 and end is also provided.
- end < 0
- If index provided AND using the HTTP header, they don't match.
This is a safeguard.
"""
if end is not None and start is None:
raise ValueError('May not specify end value without start.')
use_indexes = start is not None
if use_indexes:
if end is not None:
if start > end:
raise ValueError('start must be < end.')
elif start < 0:
raise ValueError('end cannot be set if start < 0.')
range_indexes = _serialize_range(start, end)
if use_range_set and use_range and use_indexes:
if range_header != range_indexes:
raise ValueError('May not provide non-equivalent range indexes and '
'range headers: (header) %s != (indexes) %s'
% (range_header, range_indexes))
if use_range and range_header is not None:
return range_header
elif use_indexes:
return range_indexes
else:
return None
class BlobstoreDownloadHandler(webapp.RequestHandler):
"""Base class for creating handlers that may send blobs to users."""
__use_range_unset = object()
def send_blob(self,
blob_key_or_info,
content_type=None,
save_as=None,
start=None,
end=None,
**kwargs):
"""Send a blob-response based on a blob_key.
Sets the correct response header for serving a blob. If BlobInfo
is provided and no content_type specified, will set request content type
to BlobInfo's content type.
Args:
blob_key_or_info: BlobKey or BlobInfo record to serve.
content_type: Content-type to override when known.
save_as: If True, and BlobInfo record is provided, use BlobInfos
filename to save-as. If string is provided, use string as filename.
If None or False, do not send as attachment.
start: Start index of content-range to send.
end: End index of content-range to send. End index is inclusive.
use_range: Use provided content range from requests Range header.
Mutually exclusive to start and end.
Raises:
ValueError on invalid save_as parameter.
"""
if set(kwargs) - _SEND_BLOB_PARAMETERS:
invalid_keywords = []
for keyword in kwargs:
if keyword not in _SEND_BLOB_PARAMETERS:
invalid_keywords.append(keyword)
if len(invalid_keywords) == 1:
raise TypeError('send_blob got unexpected keyword argument %s.'
% invalid_keywords[0])
else:
raise TypeError('send_blob got unexpected keyword arguments: %s'
% sorted(invalid_keywords))
use_range = kwargs.get('use_range', self.__use_range_unset)
use_range_set = use_range is not self.__use_range_unset
range_header = _check_ranges(start,
end,
use_range_set,
use_range,
self.request.headers.get('range', None))
if range_header is not None:
self.response.headers[blobstore.BLOB_RANGE_HEADER] = range_header
if isinstance(blob_key_or_info, blobstore.BlobInfo):
blob_key = blob_key_or_info.key()
blob_info = blob_key_or_info
elif isinstance(blob_key_or_info, str) and blob_key_or_info.startswith(
'/gs/'):
blob_key = blobstore.create_gs_key(blob_key_or_info)
blob_info = None
else:
blob_key = blob_key_or_info
blob_info = None
self.response.headers[blobstore.BLOB_KEY_HEADER] = str(blob_key)
if content_type:
if isinstance(content_type, unicode):
content_type = content_type.encode('utf-8')
self.response.headers['Content-Type'] = content_type
else:
del self.response.headers['Content-Type']
def send_attachment(filename):
if isinstance(filename, unicode):
filename = filename.encode('utf-8')
self.response.headers['Content-Disposition'] = (
_CONTENT_DISPOSITION_FORMAT % filename)
if save_as:
if isinstance(save_as, basestring):
send_attachment(save_as)
elif blob_info and save_as is True:
send_attachment(blob_info.filename)
else:
if not blob_info:
raise ValueError('Expected BlobInfo value for blob_key_or_info.')
else:
raise ValueError('Unexpected value for save_as.')
self.response.clear()
def get_range(self):
"""Get range from header if it exists.
A range header of "bytes: 0-100" would return (0, 100).
Returns:
Tuple (start, end):
start: Start index. None if there is None.
end: End index (inclusive). None if there is None.
None if there is no request header.
Raises:
UnsupportedRangeFormatError: If the range format in the header is
valid, but not supported.
RangeFormatError: If the range format in the header is not valid.
"""
range_header = self.request.headers.get('range', None)
if range_header is None:
return None
parsed_range = _parse_bytes(range_header)
if parsed_range is None:
raise RangeFormatError('Invalid range header: %s' % range_header)
units, ranges = parsed_range
if len(ranges) != 1:
raise UnsupportedRangeFormatError(
'Unable to support multiple range values in Range header.')
if units != _BYTES_UNIT:
raise UnsupportedRangeFormatError(
'Invalid unit in range header type: %s' % range_header)
return ranges[0]
class BlobstoreUploadHandler(webapp.RequestHandler):
"""Base class for creation blob upload handlers."""
def __init__(self, *args, **kwargs):
super(BlobstoreUploadHandler, self).__init__(*args, **kwargs)
self.__uploads = None
self.__file_infos = None
def get_uploads(self, field_name=None):
"""Get uploads sent to this handler.
Args:
field_name: Only select uploads that were sent as a specific field.
Returns:
A list of BlobInfo records corresponding to each upload.
Empty list if there are no blob-info records for field_name.
"""
if self.__uploads is None:
self.__uploads = collections.defaultdict(list)
for key, value in self.request.params.items():
if isinstance(value, cgi.FieldStorage):
if 'blob-key' in value.type_options:
self.__uploads[key].append(blobstore.parse_blob_info(value))
if field_name:
return list(self.__uploads.get(field_name, []))
else:
results = []
for uploads in self.__uploads.itervalues():
results.extend(uploads)
return results
def get_file_infos(self, field_name=None):
"""Get the file infos associated to the uploads sent to this handler.
Args:
field_name: Only select uploads that were sent as a specific field.
Specify None to select all the uploads.
Returns:
A list of FileInfo records corresponding to each upload.
Empty list if there are no FileInfo records for field_name.
"""
if self.__file_infos is None:
self.__file_infos = collections.defaultdict(list)
for key, value in self.request.params.items():
if isinstance(value, cgi.FieldStorage):
if 'blob-key' in value.type_options:
self.__file_infos[key].append(blobstore.parse_file_info(value))
if field_name:
return list(self.__file_infos.get(field_name, []))
else:
results = []
for uploads in self.__file_infos.itervalues():
results.extend(uploads)
return results