Add Date header and support If-None-Match

This commit is contained in:
Kovid Goyal 2015-05-19 13:06:11 +05:30
parent 42dd04b854
commit 45b290ad5b
4 changed files with 73 additions and 16 deletions

View File

@ -17,3 +17,11 @@ class MaxSizeExceeded(Exception):
def __init__(self, prefix, size, limit): def __init__(self, prefix, size, limit):
Exception.__init__(self, prefix + (' %d > maximum %d' % (size, limit))) Exception.__init__(self, prefix + (' %d > maximum %d' % (size, limit)))
class HTTP404(Exception):
pass
class IfNoneMatch(Exception):
def __init__(self, etag=None):
Exception.__init__(self, '')
self.etag = etag

View File

@ -13,9 +13,10 @@ from urllib import unquote
from functools import partial from functools import partial
from calibre import as_unicode from calibre import as_unicode
from calibre.srv.errors import MaxSizeExceeded, NonHTTPConnRequest from calibre.srv.errors import (
MaxSizeExceeded, NonHTTPConnRequest, HTTP404, IfNoneMatch)
from calibre.srv.respond import finalize_output, generate_static_output from calibre.srv.respond import finalize_output, generate_static_output
from calibre.srv.utils import MultiDict from calibre.srv.utils import MultiDict, http_date
HTTP1 = 'HTTP/1.0' HTTP1 = 'HTTP/1.0'
HTTP11 = 'HTTP/1.1' HTTP11 = 'HTTP/1.1'
@ -436,7 +437,7 @@ class HTTPPair(object):
self.flushed_write(msg.encode('ascii')) self.flushed_write(msg.encode('ascii'))
return True return True
def simple_response(self, status_code, msg=""): def simple_response(self, status_code, msg="", read_remaining_input=False):
abort = status_code in (httplib.REQUEST_ENTITY_TOO_LARGE, httplib.REQUEST_URI_TOO_LONG) abort = status_code in (httplib.REQUEST_ENTITY_TOO_LARGE, httplib.REQUEST_URI_TOO_LONG)
if abort: if abort:
self.close_connection = True self.close_connection = True
@ -448,13 +449,32 @@ class HTTPPair(object):
buf = [ buf = [
'%s %d %s' % (self.reponse_protocol, status_code, httplib.responses[status_code]), '%s %d %s' % (self.reponse_protocol, status_code, httplib.responses[status_code]),
"Content-Length: %s" % len(msg), "Content-Length: %s" % len(msg),
"Content-Type: text/plain; charset=UTF-8" "Content-Type: text/plain; charset=UTF-8",
"Date: " + http_date(),
] ]
if abort and self.reponse_protocol is HTTP11: if abort and self.reponse_protocol is HTTP11:
buf.append("Connection: close") buf.append("Connection: close")
buf.append('') buf.append('')
buf = [(x + '\r\n').encode('ascii') for x in buf] buf = [(x + '\r\n').encode('ascii') for x in buf]
buf.append(msg) buf.append(msg)
if read_remaining_input:
self.input_reader.read()
self.flushed_write(b''.join(buf))
def send_not_modified(self, etag=None):
buf = [
'%s %d %s' % (self.reponse_protocol, httplib.NOT_MODIFIED, httplib.responses[httplib.NOT_MODIFIED]),
"Content-Length: 0",
"Date: " + http_date(),
]
if etag is not None:
buf.append('ETag: ' + etag)
for header in ('Expires', 'Cache-Control', 'Vary'):
val = self.outheaders.get(header)
if val:
buf.append(header + ': ' + val)
buf.append('')
buf = [(x + '\r\n').encode('ascii') for x in buf]
self.flushed_write(b''.join(buf)) self.flushed_write(b''.join(buf))
def flushed_write(self, data): def flushed_write(self, data):
@ -475,13 +495,24 @@ class HTTPPair(object):
else: else:
self.input_reader = FixedSizeReader(self.conn.socket_file, self.request_content_length) self.input_reader = FixedSizeReader(self.conn.socket_file, self.request_content_length)
output = self.handle_request(self) try:
if self.status_code is None: output = self.handle_request(self)
raise Exception('Request handler did not set status_code') except HTTP404 as e:
self.simple_response(httplib.NOT_FOUND, e.message, read_remaining_input=True)
return
# Read and discard any remaining body from the HTTP request # Read and discard any remaining body from the HTTP request
self.input_reader.read() self.input_reader.read()
if self.status_code is None:
raise Exception('Request handler did not set status_code')
self.status_code, output = finalize_output(output, self.inheaders, self.outheaders, self.status_code) try:
self.status_code, output = finalize_output(output, self.inheaders, self.outheaders, self.status_code, self.response_protocol is HTTP1, self.method)
except IfNoneMatch as e:
if self.method in ('GET', 'HEAD'):
self.send_not_modified(e.etag)
else:
self.simple_response(httplib.PRECONDITION_FAILED)
return
self.send_headers() self.send_headers()

View File

@ -10,6 +10,7 @@ import os, hashlib, shutil, httplib, zlib, struct, time
from io import DEFAULT_BUFFER_SIZE, BytesIO from io import DEFAULT_BUFFER_SIZE, BytesIO
from calibre import force_unicode from calibre import force_unicode
from calibre.srv.errors import IfNoneMatch
def acceptable_encoding(val, allowed=frozenset({'gzip'})): def acceptable_encoding(val, allowed=frozenset({'gzip'})):
def enc(x): def enc(x):
@ -75,7 +76,7 @@ class FileSystemOutputFile(object):
pos = output.tell() pos = output.tell()
output.seek(0, os.SEEK_END) output.seek(0, os.SEEK_END)
self.content_length = output.tell() - pos self.content_length = output.tell() - pos
self.etag = hashlib.sha1(force_unicode(output.name or '') + str(os.fstat(output.fileno()).st_mtime)).hexdigest() self.etag = hashlib.sha1(type('')(os.fstat(output.fileno()).st_mtime) + force_unicode(output.name or '')).hexdigest()
output.seek(pos) output.seek(pos)
self.accept_ranges = True self.accept_ranges = True
@ -141,7 +142,10 @@ def generate_static_output(cache, gso_lock, name, generator):
ans = cache[name] = StaticGeneratedOutput(generator()) ans = cache[name] = StaticGeneratedOutput(generator())
return ans return ans
def finalize_output(output, inheaders, outheaders, status_code): def parse_if_none_match(val):
return {x.strip() for x in val.split(',')}
def finalize_output(output, inheaders, outheaders, status_code, is_http1, method):
ct = outheaders.get('Content-Type', '') ct = outheaders.get('Content-Type', '')
compressible = not ct or ct.startswith('text/') or ct.startswith('image/svg') or ct.startswith('application/json') compressible = not ct or ct.startswith('text/') or ct.startswith('image/svg') or ct.startswith('application/json')
if isinstance(output, file): if isinstance(output, file):
@ -153,20 +157,29 @@ def finalize_output(output, inheaders, outheaders, status_code):
else: else:
output = GeneratedOutput(output, outheaders) output = GeneratedOutput(output, outheaders)
compressible = (status_code == httplib.OK and compressible and output.content_length > 1024 and compressible = (status_code == httplib.OK and compressible and output.content_length > 1024 and
acceptable_encoding(inheaders.get('Accept-Encoding', ''))) acceptable_encoding(inheaders.get('Accept-Encoding', '')) and not is_http1)
accept_ranges = not compressible and output.accept_ranges is not None and status_code == httplib.OK accept_ranges = (not compressible and output.accept_ranges is not None and status_code == httplib.OK and
not is_http1)
ranges = None
for header in 'Accept-Ranges Content-Encoding Transfer-Encoding ETag'.split(): for header in ('Accept-Ranges', 'Content-Encoding', 'Transfer-Encoding', 'ETag', 'Content-Length'):
outheaders.pop('header', all=True) outheaders.pop('header', all=True)
# TODO: If-None-Match, Ranges, If-Range none_match = parse_if_none_match(inheaders.get('If-None-Match', ''))
matched = '*' in none_match or (output.etag and output.etag in none_match)
if matched:
raise IfNoneMatch(output.etag)
if output.etag: # TODO: Ranges, If-Range
if output.etag and method in ('GET', 'HEAD'):
outheaders.set('ETag', output.etag, replace=True) outheaders.set('ETag', output.etag, replace=True)
if accept_ranges: if accept_ranges:
outheaders.set('Accept-Ranges', 'bytes', replace=True) outheaders.set('Accept-Ranges', 'bytes', replace=True)
elif compressible: elif compressible:
outheaders.set('Content-Encoding', 'gzip', replace=True) outheaders.set('Content-Encoding', 'gzip', replace=True)
if output.content_length is not None and not compressible and not ranges:
outheaders.set('Content-Length', '%d' % output.content_length, replace=True)
if compressible or output.content_length is None: if compressible or output.content_length is None:
outheaders.set('Transfer-Encoding', 'chunked', replace=True) outheaders.set('Transfer-Encoding', 'chunked', replace=True)

View File

@ -8,8 +8,12 @@ __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
from urlparse import parse_qs from urlparse import parse_qs
import repr as reprlib import repr as reprlib
from email.utils import formatdate
class MultiDict(dict): def http_date(timeval=None):
return type('')(formatdate(timeval=timeval, usegmt=True))
class MultiDict(dict): # {{{
def __setitem__(self, key, val): def __setitem__(self, key, val):
vals = dict.get(self, key, []) vals = dict.get(self, key, [])
@ -75,3 +79,4 @@ class MultiDict(dict):
def pretty(self, leading_whitespace=''): def pretty(self, leading_whitespace=''):
return leading_whitespace + ('\n' + leading_whitespace).join('%s: %s' % (k, v) for k, v in self.items()) return leading_whitespace + ('\n' + leading_whitespace).join('%s: %s' % (k, v) for k, v in self.items())
# }}}