Skip to content

Commit

Permalink
restful: Add Last-Lodified and If-Modified-Since to imageapi.
Browse files Browse the repository at this point in the history
closes: #19
  • Loading branch information
Pawel Zembrzuski authored and lnielsen committed Aug 2, 2019
1 parent 9af51b9 commit fa98708
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 11 deletions.
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,4 @@
'flask': ('http://flask.pocoo.org/docs/', None),
'PIL': ('https://pillow.readthedocs.io/en/latest/', None),
}
nitpick_ignore = [('py:class', 'datetime')]
23 changes: 23 additions & 0 deletions flask_iiif/cache/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ def set(self, key, value, timeout=None):
:param timeout: the cache timeout in seconds
"""

def get_last_modification(self, key):
"""Get last modification of cached file.
:param key: the file object's key
"""

def set_last_modification(self, key, last_modification=None, timeout=None):
"""Set last modification of cached file.
:param key: the file object's key
:param last_modification: Last modification date of
file represented by the key
:type last_modification: datetime
:param timeout: the cache timeout in seconds
"""

def _last_modification_key_name(self, key):
"""Generate key for last_modification entry of specified key.
:param key: the file object's key
"""
return "last_modification::%s" % key

def delete(self, key):
"""Delete the specific key."""

Expand Down
35 changes: 34 additions & 1 deletion flask_iiif/cache/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from __future__ import absolute_import

from datetime import datetime

from flask import current_app
from redis import StrictRedis
from werkzeug.contrib.cache import RedisCache
Expand Down Expand Up @@ -50,10 +52,41 @@ def set(self, key, value, timeout=None):
"""
timeout = timeout if timeout else self.timeout
self.cache.set(key, value, timeout=timeout)
self.set_last_modification(key, timeout=timeout)

def get_last_modification(self, key):
"""Get last modification of cached file.
:param key: the file object's key
"""
last = self.cache.get(self._last_modification_key_name(key))
return last

def set_last_modification(self, key, last_modification=None, timeout=None):
"""Set last modification of cached file.
:param key: the file object's key
:param last_modification: Last modification date of
file represented by the key
:type last_modification: datetime
:param timeout: the cache timeout in seconds
"""
if not key:
return
if not last_modification:
last_modification = datetime.utcnow().replace(microsecond=0)
timeout = timeout if timeout else self.timeout
self.cache.set(
self._last_modification_key_name(key),
last_modification,
timeout
)

def delete(self, key):
"""Delete the specific key."""
self.cache.delete(key)
if key:
self.cache.delete(key)
self.cache.delete(self._last_modification_key_name(key))

def flush(self):
"""Flush the cache."""
Expand Down
35 changes: 34 additions & 1 deletion flask_iiif/cache/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from __future__ import absolute_import

from datetime import datetime

from werkzeug.contrib.cache import SimpleCache

from .cache import ImageCache
Expand Down Expand Up @@ -43,10 +45,41 @@ def set(self, key, value, timeout=None):
"""
timeout = timeout if timeout else self.timeout
self.cache.set(key, value, timeout)
self.set_last_modification(key, timeout=timeout)

def get_last_modification(self, key):
"""Get last modification of cached file.
:param key: the file object's key
"""
last = self.cache.get(self._last_modification_key_name(key))
return last

def set_last_modification(self, key, last_modification=None, timeout=None):
"""Set last modification of cached file.
:param key: the file object's key
:param last_modification: Last modification date of
file represented by the key
:type last_modification: datetime
:param timeout: the cache timeout in seconds
"""
if not key:
return
if not last_modification:
last_modification = datetime.utcnow().replace(microsecond=0)
timeout = timeout if timeout else self.timeout
self.cache.set(
self._last_modification_key_name(key),
last_modification,
timeout
)

def delete(self, key):
"""Delete the specific key."""
self.cache.delete(key)
if key:
self.cache.delete(key)
self.cache.delete(self._last_modification_key_name(key))

def flush(self):
"""Flush the cache."""
Expand Down
34 changes: 28 additions & 6 deletions flask_iiif/restful.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
# more details.

"""Multimedia IIIF Image API."""

import datetime
from email.utils import parsedate
from io import BytesIO

from flask import current_app, jsonify, redirect, request, send_file, url_for
import flask
from flask import Response, current_app, jsonify, redirect, request, \
send_file, url_for
from flask_restful import Resource
from flask_restful.utils import cors
from werkzeug import LocalProxy
Expand All @@ -21,7 +24,7 @@
from .decorators import api_decorator, error_handler
from .signals import iiif_after_info_request, iiif_after_process_request, \
iiif_before_info_request, iiif_before_process_request
from .utils import should_cache
from .utils import datetime_to_float, should_cache

current_iiif = LocalProxy(lambda: current_app.extensions['iiif'])

Expand Down Expand Up @@ -171,6 +174,8 @@ def get(self, version, uuid, region, size, rotation, quality,
if should_cache(request.args):
cache_handler.set(key, to_serve.getvalue())

last_modified = cache_handler.get_last_modification(key)

# decide the mime_type from the requested image_format
mimetype = current_app.config['IIIF_FORMATS'].get(
image_format, 'image/jpeg'
Expand All @@ -183,8 +188,16 @@ def get(self, version, uuid, region, size, rotation, quality,

# Trigger event after proccess the api request
iiif_after_process_request.send(self, **api_after_request_parameters)

send_file_kwargs = {'mimetype': mimetype}
# last_modified is not supported before flask 0.12
additional_headers = []
if last_modified and flask.__version__ in ['0.10.1', '0.11', '0.11.1']:
additional_headers = [
(u'Last-Modified', (datetime_to_float(last_modified)))
]
elif last_modified:
send_file_kwargs.update(last_modified=last_modified)

if 'dl' in request.args:
filename = secure_filename(request.args.get('dl', ''))
if filename.lower() in {'', '1', 'true'}:
Expand All @@ -195,5 +208,14 @@ def get(self, version, uuid, region, size, rotation, quality,
as_attachment=True,
attachment_filename=secure_filename(filename),
)

return send_file(to_serve, **send_file_kwargs)
if_modified_since_raw = request.headers.get('If-Modified-Since')
if if_modified_since_raw:
if_modified_since = datetime.datetime(
*parsedate(if_modified_since_raw)[:6]
)
if if_modified_since and if_modified_since >= last_modified:
return Response(status=304)
response = send_file(to_serve, **send_file_kwargs)
if additional_headers:
response.headers.extend(additional_headers)
return response
9 changes: 9 additions & 0 deletions flask_iiif/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
# more details.

"""Flask-IIIF utilities."""
import datetime
import shutil
import tempfile
from email.utils import formatdate
from os.path import dirname, join

from flask import abort, url_for
Expand Down Expand Up @@ -152,3 +154,10 @@ def should_cache(request_args):
and request_args['cache-control'] in ["no-cache", "no-store"]:
return False
return True


def datetime_to_float(date):
"""Convert datetime to string accepted by browsers as per RFC 2822."""
epoch = datetime.datetime.utcfromtimestamp(0)
total_seconds = (date - epoch).total_seconds()
return formatdate(total_seconds, usegmt=True)
7 changes: 5 additions & 2 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def create_app(self):
app.config['SERVER_NAME'] = "shield.worker.node.1"
app.config['SITE_URL'] = "http://shield.worker.node.1"
app.config['IIIF_CACHE_HANDLER'] = ImageSimpleCache()
app.config['PRESERVE_CONTEXT_ON_EXCEPTION'] = False
app.logger.disabled = True

api = Api(app=app)
Expand Down Expand Up @@ -69,12 +70,14 @@ def patch(self, *args, **kwargs):
"""Simulate a PATCH request."""
return self.make_request(self.client.patch, *args, **kwargs)

def make_request(self, client_func, endpoint, urlargs=None):
def make_request(self, client_func, endpoint, urlargs=None, *args, **kwargs):
"""Simulate a request."""
url = url_for(endpoint, **(urlargs or {}))
response = client_func(
url,
base_url=self.app.config['SITE_URL']
base_url=self.app.config['SITE_URL'],
*args,
**kwargs
)
return response

Expand Down
27 changes: 26 additions & 1 deletion tests/test_restful_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@ def test_api_stream_image(self):
image = Image.new("RGBA", (1280, 1024), (255, 0, 0, 0))
image.save(tmp_file, 'png')
tmp_file.seek(0)

get_the_response = self.get(
'iiifimageapi',
urlargs=dict(
Expand All @@ -192,12 +191,38 @@ def test_api_stream_image(self):
image_format='png'
)
)
# Check if returns `Last-Modified` key in headers
# required for `If-Modified-Since`
self.assertTrue(
'Last-Modified' in get_the_response.headers
)

last_modified = get_the_response.headers['Last-Modified']

self.assertEqual(
get_the_response.data,
tmp_file.getvalue()
)

# Test `If-Modified-Since` recognized properly
get_the_response = self.get(
'iiifimageapi',
urlargs=dict(
uuid=u'valid:id-üni',
version='v2',
region='full',
size='full',
rotation='0',
quality='default',
image_format='png'
),
headers={
'If-Modified-Since': last_modified
}
)

self.assertEqual(get_the_response.status_code, 304)

urlargs = dict(
uuid=u'valid:id-üni',
version='v2',
Expand Down

0 comments on commit fa98708

Please sign in to comment.