Allow serving etagged dynamic content that is only generated on demand

This commit is contained in:
Kovid Goyal 2015-12-21 15:33:51 +05:30
parent e5180db446
commit e3c8ab98b1
2 changed files with 50 additions and 3 deletions

View File

@ -230,6 +230,15 @@ class RequestData(object): # {{{
tuple(map(lambda x:etag.update(string(x)), etag_parts)) tuple(map(lambda x:etag.update(string(x)), etag_parts))
return ETaggedFile(output, etag.hexdigest()) return ETaggedFile(output, etag.hexdigest())
def etagged_dynamic_response(self, etag, func, content_type='text/html; charset=UTF-8'):
' A response that is generated only if the etag does not match '
ct = self.outheaders.get('Content-Type')
if not ct:
self.outheaders.set('Content-Type', content_type, replace_all=True)
if not etag.endswith('"'):
etag = '"%s"' % etag
return ETaggedDynamicOutput(func, etag)
def read(self, size=-1): def read(self, size=-1):
return self.request_body_file.read(size) return self.request_body_file.read(size)
@ -286,7 +295,7 @@ def filesystem_file_output(output, outheaders, stat_result):
self.use_sendfile = True self.use_sendfile = True
return self return self
def dynamic_output(output, outheaders): def dynamic_output(output, outheaders, etag=None):
if isinstance(output, bytes): if isinstance(output, bytes):
data = output data = output
else: else:
@ -294,10 +303,18 @@ def dynamic_output(output, outheaders):
ct = outheaders.get('Content-Type') ct = outheaders.get('Content-Type')
if not ct: if not ct:
outheaders.set('Content-Type', 'text/plain; charset=UTF-8', replace_all=True) outheaders.set('Content-Type', 'text/plain; charset=UTF-8', replace_all=True)
ans = ReadableOutput(ReadOnlyFileBuffer(data)) ans = ReadableOutput(ReadOnlyFileBuffer(data), etag=etag)
ans.accept_ranges = False ans.accept_ranges = False
return ans return ans
class ETaggedDynamicOutput(object):
def __init__(self, func, etag):
self.func, self.etag = func, etag
def __call__(self):
return self.func()
class GeneratedOutput(object): class GeneratedOutput(object):
def __init__(self, output, etag=None): def __init__(self, output, etag=None):
@ -550,6 +567,16 @@ class HTTPConnection(HTTPRequest):
self.simple_response(httplib.INTERNAL_SERVER_ERROR) self.simple_response(httplib.INTERNAL_SERVER_ERROR)
def finalize_output(self, output, request, is_http1): def finalize_output(self, output, request, is_http1):
none_match = parse_if_none_match(request.inheaders.get('If-None-Match', ''))
if isinstance(output, ETaggedDynamicOutput):
matched = '*' in none_match or (output.etag and output.etag in none_match)
if matched:
if self.method in ('GET', 'HEAD'):
self.send_not_modified(output.etag)
else:
self.simple_response(httplib.PRECONDITION_FAILED)
return
opts = self.opts opts = self.opts
outheaders = request.outheaders outheaders = request.outheaders
stat_result = file_metadata(output) stat_result = file_metadata(output)
@ -567,6 +594,8 @@ class HTTPConnection(HTTPRequest):
output = ReadableOutput(output) output = ReadableOutput(output)
elif isinstance(output, StaticOutput): elif isinstance(output, StaticOutput):
output = ReadableOutput(ReadOnlyFileBuffer(output.data), etag=output.etag, content_length=output.content_length) output = ReadableOutput(ReadOnlyFileBuffer(output.data), etag=output.etag, content_length=output.content_length)
elif isinstance(output, ETaggedDynamicOutput):
output = dynamic_output(output(), outheaders, etag=output.etag)
else: else:
output = GeneratedOutput(output) output = GeneratedOutput(output)
ct = outheaders.get('Content-Type', '').partition(';')[0] ct = outheaders.get('Content-Type', '').partition(';')[0]
@ -587,7 +616,6 @@ class HTTPConnection(HTTPRequest):
for header in ('Accept-Ranges', 'Content-Encoding', 'Transfer-Encoding', 'ETag', 'Content-Length'): for header in ('Accept-Ranges', 'Content-Encoding', 'Transfer-Encoding', 'ETag', 'Content-Length'):
outheaders.pop('header', all=True) outheaders.pop('header', all=True)
none_match = parse_if_none_match(request.inheaders.get('If-None-Match', ''))
matched = '*' in none_match or (output.etag and output.etag in none_match) matched = '*' in none_match or (output.etag and output.etag in none_match)
if matched: if matched:
if self.method in ('GET', 'HEAD'): if self.method in ('GET', 'HEAD'):

View File

@ -316,6 +316,25 @@ class TestHTTP(BaseTest):
self.ae(str(len(raw)), r.getheader('Calibre-Uncompressed-Length')) self.ae(str(len(raw)), r.getheader('Calibre-Uncompressed-Length'))
self.ae(r.status, httplib.OK), self.ae(zlib.decompress(r.read(), 16+zlib.MAX_WBITS), raw) self.ae(r.status, httplib.OK), self.ae(zlib.decompress(r.read(), 16+zlib.MAX_WBITS), raw)
# Test dynamic etagged content
num_calls = [0]
def edfunc():
num_calls[0] += 1
return b'data'
server.change_handler(lambda conn:conn.etagged_dynamic_response("xxx", edfunc))
conn = server.connect()
conn.request('GET', '/an_etagged_path')
r = conn.getresponse()
self.ae(r.status, httplib.OK), self.ae(r.read(), b'data')
etag = r.getheader('ETag')
self.ae(etag, b'"xxx"')
self.ae(r.getheader('Content-Length'), '4')
conn.request('GET', '/an_etagged_path', headers={'If-None-Match':etag})
r = conn.getresponse()
self.ae(r.status, httplib.NOT_MODIFIED)
self.ae(r.read(), b'')
self.ae(num_calls[0], 1)
# Test getting a filesystem file # Test getting a filesystem file
for use_sendfile in (True, False): for use_sendfile in (True, False):
server.change_handler(lambda conn: f) server.change_handler(lambda conn: f)