diff --git a/nanohttp/__init__.py b/nanohttp/__init__.py index 648780b..8353829 100644 --- a/nanohttp/__init__.py +++ b/nanohttp/__init__.py @@ -4,7 +4,7 @@ HttpMethodNotAllowed, HttpConflict, HttpGone, HttpRedirect, HttpMovedPermanently, HttpFound, \ HttpInternalServerError from .controllers import Controller, RestController, Static -from .decorators import action, html, json, xml, binary, text +from .decorators import action, html, json, xml, binary, text, must_revalidate from .helpers import quickstart, LazyAttribute from .cli import main from .contexts import context, ContextIsNotInitializedError diff --git a/nanohttp/contexts.py b/nanohttp/contexts.py index e74ff13..50b5e1f 100644 --- a/nanohttp/contexts.py +++ b/nanohttp/contexts.py @@ -141,6 +141,16 @@ def encode_response(self, buffer): except AttributeError: # pragma: no cover raise TypeError('The returned response should has the `encode` attribute, such as `str`.') + def expired(self, etag, force=False): + none_match = self.environ.get('HTTP_IF_NONE_MATCH') + match = self.environ.get('HTTP_IF_MATCH') + expired = etag not in (none_match, match) + + if force and not expired: + raise exceptions.HttpNotModified() + + return expired + class ContextProxy(Context): diff --git a/nanohttp/decorators.py b/nanohttp/decorators.py index 39272be..bdd8a7d 100644 --- a/nanohttp/decorators.py +++ b/nanohttp/decorators.py @@ -2,6 +2,7 @@ import functools from .configuration import settings +from .contexts import context def action(*args, verbs='any', encoding='utf-8', content_type=None, inner_decorator=None, **kwargs): @@ -48,3 +49,16 @@ def wrapper(*args, **kwargs): json = functools.partial(action, content_type='application/json', inner_decorator=jsonify) xml = functools.partial(action, content_type='application/xml') binary = functools.partial(action, content_type='application/octet-stream', encoding=None) + + +def must_revalidate(etag): + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + etag_ = etag() if callable(etag) else etag + context.expired(etag_, force=True) + context.response_headers.add_header('Cache-Control', 'must-revalidate') + context.response_headers.add_header('ETag', etag_) + return func(*args, **kwargs) + return wrapper + return decorator diff --git a/nanohttp/exceptions.py b/nanohttp/exceptions.py index 9dbad91..9c731dd 100644 --- a/nanohttp/exceptions.py +++ b/nanohttp/exceptions.py @@ -71,11 +71,15 @@ def __init__(self, location, *args, **kw): class HttpMovedPermanently(HttpRedirect): - status_code, status_text, info = 301, 'Moved Permanently', 'Object moved permanently -- see URI list' + status_code, status_text, info = 301, 'Moved Permanently', 'Object moved permanently' class HttpFound(HttpRedirect): - status_code, status_text, info = 302, 'Found', 'Object moved temporarily -- see URI list' + status_code, status_text, info = 302, 'Found', 'Object moved temporarily' + + +class HttpNotModified(HttpStatus): + status_code, status_text, info = 304, 'Not Modified', 'Resource is not modified' class HttpInternalServerError(HttpStatus): diff --git a/nanohttp/tests/test_caching.py b/nanohttp/tests/test_caching.py new file mode 100644 index 0000000..a9d3cbc --- /dev/null +++ b/nanohttp/tests/test_caching.py @@ -0,0 +1,44 @@ +import unittest + +from nanohttp import Controller, text, must_revalidate +from nanohttp.tests.helpers import WsgiAppTestCase + + +_etag = '1' + + +class CachingTestCase(WsgiAppTestCase): + + class Root(Controller): + + @text() + @must_revalidate(etag=lambda: _etag) + def index(self): + yield 'Something' + + @text() + def about(self): + yield 'about' + + def test_caching_header(self): + global _etag + self.assert_get('/', expected_headers={'Cache-Control': 'must-revalidate', 'ETag': _etag}) + + # Fetching again with etag header + ___, body = self.assert_get('/', headers={'If-None-Match': _etag}, status=304) + self.assertEqual(len(body), 0) + + _old_etag = _etag + _etag = '2' + # Fetching again with etag header + ___, body = self.assert_get( + '/', + headers={'If-None-Match': _old_etag}, + status=200, + expected_headers={'Cache-Control': 'must-revalidate', 'ETag': _etag} + ) + self.assertEqual(body, b'Something') + + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/nanohttp/tests/test_redirect.py b/nanohttp/tests/test_redirect.py index f90f787..9aabe95 100644 --- a/nanohttp/tests/test_redirect.py +++ b/nanohttp/tests/test_redirect.py @@ -1,3 +1,5 @@ +import unittest + from nanohttp import Controller, action, text, HttpMovedPermanently, HttpFound from nanohttp.tests.helpers import WsgiAppTestCase @@ -18,3 +20,6 @@ def test_redirect_response_header(self): self.assert_get('/', status=301, expected_headers={'Location': '/new/address'}) self.assert_get('/about', status=302, expected_headers={'Location': '/new/address'}) + +if __name__ == '__main__': # pragma: no cover + unittest.main()