Added Range request options for static files
This commit is contained in:
parent
6d18fb6bae
commit
76a94226f1
|
@ -104,6 +104,7 @@ INTERNAL_SERVER_ERROR_HTML = '''
|
|||
class SanicException(Exception):
|
||||
def __init__(self, message, status_code=None):
|
||||
super().__init__(message)
|
||||
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
|
||||
|
@ -137,6 +138,17 @@ class PayloadTooLarge(SanicException):
|
|||
status_code = 413
|
||||
|
||||
|
||||
class ContentRangeError(SanicException):
|
||||
status_code = 416
|
||||
|
||||
def __init__(self, message, content_range):
|
||||
super().__init__(message)
|
||||
self.headers = {
|
||||
'Content-Type': 'text/plain',
|
||||
"Content-Range": "bytes */%s" % (content_range.total,)
|
||||
}
|
||||
|
||||
|
||||
class Handler:
|
||||
handlers = None
|
||||
|
||||
|
@ -191,7 +203,9 @@ class Handler:
|
|||
if isinstance(exception, SanicException):
|
||||
return text(
|
||||
'Error: {}'.format(exception),
|
||||
status=getattr(exception, 'status_code', 500))
|
||||
status=getattr(exception, 'status_code', 500),
|
||||
headers=getattr(exception, 'headers', dict())
|
||||
)
|
||||
elif self.debug:
|
||||
html_output = self._render_traceback_html(exception, request)
|
||||
|
||||
|
|
|
@ -97,21 +97,27 @@ class HTTPResponse:
|
|||
def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None):
|
||||
# This is all returned in a kind-of funky way
|
||||
# We tried to make this as fast as possible in pure python
|
||||
timeout_header = b''
|
||||
if keep_alive and keep_alive_timeout:
|
||||
timeout_header = b'Keep-Alive: timeout=%d\r\n' % keep_alive_timeout
|
||||
|
||||
if 'Keep-Alive' not in self.headers:
|
||||
self.headers['Keep-Alive'] = keep_alive_timeout
|
||||
if 'Connection' not in self.headers:
|
||||
if keep_alive:
|
||||
self.headers['Connection'] = 'keep-alive'
|
||||
else:
|
||||
self.headers['Connection'] = 'close'
|
||||
if 'Content-Length' not in self.headers:
|
||||
self.headers['Content-Length'] = len(self.body)
|
||||
if 'Content-Type' not in self.headers:
|
||||
self.headers['Content-Type'] = self.content_type
|
||||
headers = b''
|
||||
if self.headers:
|
||||
for name, value in self.headers.items():
|
||||
try:
|
||||
headers += (
|
||||
b'%b: %b\r\n' % (name.encode(), value.encode('utf-8')))
|
||||
headers += (b'%b: %b\r\n' % (
|
||||
name.encode(), value.encode('utf-8')))
|
||||
except AttributeError:
|
||||
headers += (
|
||||
b'%b: %b\r\n' % (
|
||||
headers += (b'%b: %b\r\n' % (
|
||||
str(name).encode(), str(value).encode('utf-8')))
|
||||
|
||||
# Try to pull from the common codes first
|
||||
# Speeds up response rate 6% over pulling from all
|
||||
status = COMMON_STATUS_CODES.get(self.status)
|
||||
|
@ -119,18 +125,11 @@ class HTTPResponse:
|
|||
status = ALL_STATUS_CODES.get(self.status)
|
||||
|
||||
return (b'HTTP/%b %d %b\r\n'
|
||||
b'Content-Type: %b\r\n'
|
||||
b'Content-Length: %d\r\n'
|
||||
b'Connection: %b\r\n'
|
||||
b'%b%b\r\n'
|
||||
b'%b\r\n'
|
||||
b'%b') % (
|
||||
version.encode(),
|
||||
self.status,
|
||||
status,
|
||||
self.content_type.encode(),
|
||||
len(self.body),
|
||||
b'keep-alive' if keep_alive else b'close',
|
||||
timeout_header,
|
||||
headers,
|
||||
self.body
|
||||
)
|
||||
|
@ -142,13 +141,62 @@ class HTTPResponse:
|
|||
return self._cookies
|
||||
|
||||
|
||||
class ContentRangeHandler:
|
||||
"""
|
||||
This class is for parsing the request header
|
||||
"""
|
||||
__slots__ = ('start', 'end', 'size', 'total', 'headers')
|
||||
|
||||
def __init__(self, request, stats):
|
||||
self.start = self.size = 0
|
||||
self.end = None
|
||||
self.headers = dict()
|
||||
self.total = stats.st_size
|
||||
range_header = request.headers.get('Range')
|
||||
if range_header:
|
||||
self.start, self.end = ContentRangeHandler.parse_range(range_header)
|
||||
if self.start is not None and self.end is not None:
|
||||
self.size = self.end - self.start
|
||||
elif self.end is not None:
|
||||
self.size = self.end
|
||||
elif self.start is not None:
|
||||
self.size = self.total - self.start
|
||||
else:
|
||||
self.size = self.total
|
||||
self.headers['Content-Range'] = "bytes %s-%s/%s" % (
|
||||
self.start, self.end, self.total)
|
||||
else:
|
||||
self.size = self.total
|
||||
|
||||
def __bool__(self):
|
||||
return self.size > 0
|
||||
|
||||
@staticmethod
|
||||
def parse_range(range_header):
|
||||
unit, _, value = tuple(map(str.strip, range_header.partition('=')))
|
||||
if unit != 'bytes':
|
||||
return None
|
||||
start_b, _, end_b = tuple(map(str.strip, value.partition('-')))
|
||||
try:
|
||||
start = int(start_b) if start_b.strip() else None
|
||||
end = int(end_b) if end_b.strip() else None
|
||||
except ValueError:
|
||||
return None
|
||||
if end is not None:
|
||||
if start is None:
|
||||
if end != 0:
|
||||
start = -end
|
||||
end = None
|
||||
return start, end
|
||||
|
||||
|
||||
def json(body, status=200, headers=None, **kwargs):
|
||||
"""
|
||||
Returns response object with body in json format.
|
||||
:param body: Response data to be serialized.
|
||||
:param status: Response code.
|
||||
:param headers: Custom Headers.
|
||||
:param \**kwargs: Remaining arguments that are passed to the json encoder.
|
||||
:param kwargs: Remaining arguments that are passed to the json encoder.
|
||||
"""
|
||||
return HTTPResponse(json_dumps(body, **kwargs), headers=headers,
|
||||
status=status, content_type="application/json")
|
||||
|
@ -176,16 +224,23 @@ def html(body, status=200, headers=None):
|
|||
content_type="text/html; charset=utf-8")
|
||||
|
||||
|
||||
async def file(location, mime_type=None, headers=None):
|
||||
async def file(location, mime_type=None, headers=None, _range=None):
|
||||
"""
|
||||
Returns response object with file data.
|
||||
:param location: Location of file on system.
|
||||
:param mime_type: Specific mime_type.
|
||||
:param headers: Custom Headers.
|
||||
:param _range:
|
||||
"""
|
||||
filename = path.split(location)[-1]
|
||||
|
||||
async with open_async(location, mode='rb') as _file:
|
||||
if _range:
|
||||
await _file.seek(_range.start)
|
||||
out_stream = await _file.read(_range.size)
|
||||
headers['Content-Range'] = 'bytes %s-%s/%s' % (
|
||||
_range.start, _range.end, _range.total)
|
||||
else:
|
||||
out_stream = await _file.read()
|
||||
|
||||
mime_type = mime_type or guess_type(filename)[0] or 'text/plain'
|
||||
|
|
|
@ -75,22 +75,22 @@ class Sanic:
|
|||
|
||||
# Shorthand method decorators
|
||||
def get(self, uri, host=None):
|
||||
return self.route(uri, methods=["GET"], host=host)
|
||||
return self.route(uri, methods=frozenset({"GET"}), host=host)
|
||||
|
||||
def post(self, uri, host=None):
|
||||
return self.route(uri, methods=["POST"], host=host)
|
||||
return self.route(uri, methods=frozenset({"POST"}), host=host)
|
||||
|
||||
def put(self, uri, host=None):
|
||||
return self.route(uri, methods=["PUT"], host=host)
|
||||
return self.route(uri, methods=frozenset({"PUT"}), host=host)
|
||||
|
||||
def head(self, uri, host=None):
|
||||
return self.route(uri, methods=["HEAD"], host=host)
|
||||
return self.route(uri, methods=frozenset({"HEAD"}), host=host)
|
||||
|
||||
def options(self, uri, host=None):
|
||||
return self.route(uri, methods=["OPTIONS"], host=host)
|
||||
return self.route(uri, methods=frozenset({"OPTIONS"}), host=host)
|
||||
|
||||
def patch(self, uri, host=None):
|
||||
return self.route(uri, methods=["PATCH"], host=host)
|
||||
return self.route(uri, methods=frozenset({"PATCH"}), host=host)
|
||||
|
||||
def delete(self, uri, host=None):
|
||||
return self.route(uri, methods=["DELETE"], host=host)
|
||||
|
@ -121,7 +121,7 @@ class Sanic:
|
|||
"""
|
||||
Decorates a function to be registered as a handler for exceptions
|
||||
|
||||
:param \*exceptions: exceptions
|
||||
:param exceptions: exceptions
|
||||
:return: decorated function
|
||||
"""
|
||||
|
||||
|
@ -156,13 +156,13 @@ class Sanic:
|
|||
|
||||
# Static Files
|
||||
def static(self, uri, file_or_directory, pattern='.+',
|
||||
use_modified_since=True):
|
||||
use_modified_since=True, use_content_range=False):
|
||||
"""
|
||||
Registers a root to serve files from. The input can either be a file
|
||||
or a directory. See
|
||||
"""
|
||||
static_register(self, uri, file_or_directory, pattern,
|
||||
use_modified_since)
|
||||
use_modified_since, use_content_range)
|
||||
|
||||
def blueprint(self, blueprint, **options):
|
||||
"""
|
||||
|
|
|
@ -2,13 +2,15 @@ from aiofiles.os import stat
|
|||
from os import path
|
||||
from re import sub
|
||||
from time import strftime, gmtime
|
||||
from mimetypes import guess_type
|
||||
from urllib.parse import unquote
|
||||
|
||||
from .exceptions import FileNotFound, InvalidUsage
|
||||
from .response import file, HTTPResponse
|
||||
from .exceptions import FileNotFound, InvalidUsage, ContentRangeError
|
||||
from .response import file, HTTPResponse, ContentRangeHandler
|
||||
|
||||
|
||||
def register(app, uri, file_or_directory, pattern, use_modified_since):
|
||||
def register(app, uri, file_or_directory, pattern,
|
||||
use_modified_since, use_content_range):
|
||||
# TODO: Though sanic is not a file server, I feel like we should at least
|
||||
# make a good effort here. Modified-since is nice, but we could
|
||||
# also look into etags, expires, and caching
|
||||
|
@ -23,8 +25,9 @@ def register(app, uri, file_or_directory, pattern, use_modified_since):
|
|||
:param use_modified_since: If true, send file modified time, and return
|
||||
not modified if the browser's matches the
|
||||
server's
|
||||
:param use_content_range: If true, process header for range requests
|
||||
and sends the file part that is requested
|
||||
"""
|
||||
|
||||
# If we're not trying to match a file directly,
|
||||
# serve from the folder
|
||||
if not path.isfile(file_or_directory):
|
||||
|
@ -50,6 +53,7 @@ def register(app, uri, file_or_directory, pattern, use_modified_since):
|
|||
headers = {}
|
||||
# Check if the client has been sent this file before
|
||||
# and it has not been modified since
|
||||
stats = None
|
||||
if use_modified_since:
|
||||
stats = await stat(file_path)
|
||||
modified_since = strftime('%a, %d %b %Y %H:%M:%S GMT',
|
||||
|
@ -57,11 +61,36 @@ def register(app, uri, file_or_directory, pattern, use_modified_since):
|
|||
if request.headers.get('If-Modified-Since') == modified_since:
|
||||
return HTTPResponse(status=304)
|
||||
headers['Last-Modified'] = modified_since
|
||||
|
||||
return await file(file_path, headers=headers)
|
||||
except:
|
||||
_range = None
|
||||
if use_content_range:
|
||||
if not stats:
|
||||
stats = await stat(file_path)
|
||||
headers['Accept-Ranges'] = 'bytes'
|
||||
headers['Content-Length'] = str(stats.st_size)
|
||||
if request.method != 'HEAD':
|
||||
_range = ContentRangeHandler(request, stats)
|
||||
# If the start byte is greater than the size
|
||||
# of the entire file or if the end is
|
||||
if _range.start >= _range.total or _range.end == 0:
|
||||
raise ContentRangeError('Content-Range malformed',
|
||||
_range)
|
||||
if _range.start == 0 and _range.size == _range.total:
|
||||
_range = None
|
||||
else:
|
||||
headers['Content-Length'] = str(_range.size)
|
||||
for k, v in _range.headers.items():
|
||||
headers[k] = v
|
||||
if request.method == 'HEAD':
|
||||
return HTTPResponse(
|
||||
headers=headers,
|
||||
content_type=guess_type(file_path)[0] or 'text/plain')
|
||||
else:
|
||||
return await file(file_path, headers=headers, _range=_range)
|
||||
except ContentRangeError:
|
||||
raise
|
||||
except Exception:
|
||||
raise FileNotFound('File not found',
|
||||
path=file_or_directory,
|
||||
relative_url=file_uri)
|
||||
|
||||
app.route(uri, methods=['GET'])(_handler)
|
||||
app.route(uri, methods=['GET', 'HEAD'])(_handler)
|
||||
|
|
Loading…
Reference in New Issue
Block a user