diff --git a/docs/user/tutorial.rst b/docs/user/tutorial.rst index 2df08cebf..9e483d3ed 100644 --- a/docs/user/tutorial.rst +++ b/docs/user/tutorial.rst @@ -1174,7 +1174,7 @@ Go ahead and edit your ``images.py`` file to look something like this: _CHUNK_SIZE_BYTES = 4096 _IMAGE_NAME_PATTERN = re.compile( - '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$' + r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$' ) def __init__(self, storage_path, uuidgen=uuid.uuid4, fopen=io.open): @@ -1307,7 +1307,7 @@ Inspecting the application now returns: Query Strings ------------- Now that we are able to get the images from the service, we need a way to get -a list available images. We have already set up this route. Before testing this +a list of available images. We have already set up this route. Before testing this route let's change its output format back to JSON to have a more terminal-friendly output. The top of file ``images.py`` should look like this: @@ -1390,7 +1390,7 @@ and also to enable a minimum value validation. self._image_store = image_store def on_get(self, req, resp): - max_size = req.get_param_as_int("maxsize", min_value=1, default=-1)) + max_size = req.get_param_as_int("maxsize", min_value=1, default=-1) images = self._image_store.list(max_size) doc = { 'images': [ @@ -1421,7 +1421,7 @@ and also to enable a minimum value validation. _CHUNK_SIZE_BYTES = 4096 _IMAGE_NAME_PATTERN = re.compile( - '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$' + r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$' ) def __init__(self, storage_path, uuidgen=uuid.uuid4, fopen=io.open): diff --git a/examples/look/look/app.py b/examples/look/look/app.py index abce808bd..c395c4446 100644 --- a/examples/look/look/app.py +++ b/examples/look/look/app.py @@ -2,14 +2,13 @@ import falcon -from .images import ImageStore -from .images import Resource +from .images import Collection, ImageStore, Item def create_app(image_store): - image_resource = Resource(image_store) app = falcon.App() - app.add_route('/images', image_resource) + app.add_route('/images', Collection(image_store)) + app.add_route('/images/{name}', Item(image_store)) return app @@ -17,3 +16,4 @@ def get_app(): storage_path = os.environ.get('LOOK_STORAGE_PATH', '.') image_store = ImageStore(storage_path) return create_app(image_store) + diff --git a/examples/look/look/images.py b/examples/look/look/images.py index 31466d93c..8d099d99f 100644 --- a/examples/look/look/images.py +++ b/examples/look/look/images.py @@ -1,28 +1,28 @@ import io import mimetypes import os +import re import uuid -import msgpack +import json import falcon -class Resource: +class Collection: def __init__(self, image_store): self._image_store = image_store def on_get(self, req, resp): + max_size = req.get_param_as_int("maxsize", min_value=1, default=-1) + images = self._image_store.list(max_size) doc = { 'images': [ - { - 'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png', - }, - ], + {'href': '/images/' + image} for image in images + ] } - resp.data = msgpack.packb(doc, use_bin_type=True) - resp.content_type = 'application/msgpack' + resp.text = json.dumps(doc, ensure_ascii=False) resp.status = falcon.HTTP_200 def on_post(self, req, resp): @@ -31,8 +31,21 @@ def on_post(self, req, resp): resp.location = '/images/' + name +class Item: + + def __init__(self, image_store): + self._image_store = image_store + + def on_get(self, req, resp, name): + resp.content_type = mimetypes.guess_type(name)[0] + resp.stream, resp.content_length = self._image_store.open(name) + + class ImageStore: _CHUNK_SIZE_BYTES = 4096 + _IMAGE_NAME_PATTERN = re.compile( + r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$' + ) # Note the use of dependency injection for standard library # methods. We'll use these later to avoid monkey-patching. @@ -55,3 +68,26 @@ def save(self, image_stream, image_content_type): image_file.write(chunk) return name + + def open(self, name): + # Always validate untrusted input! + if not self._IMAGE_NAME_PATTERN.match(name): + raise IOError('File not found') + + image_path = os.path.join(self._storage_path, name) + stream = self._fopen(image_path, 'rb') + content_length = os.path.getsize(image_path) + + return stream, content_length + + def list(self, max_size): + images = [ + image for image in os.listdir(self._storage_path) + if self._IMAGE_NAME_PATTERN.match(image) + and ( + max_size == -1 + or os.path.getsize(os.path.join(self._storage_path, image)) <= max_size + ) + ] + return images + diff --git a/examples/look/tests/test_app.py b/examples/look/tests/test_app.py index c6db6451c..8fce0828e 100644 --- a/examples/look/tests/test_app.py +++ b/examples/look/tests/test_app.py @@ -1,4 +1,8 @@ import io +import json +import os +import uuid +from unittest import TestCase from unittest.mock import call from unittest.mock import MagicMock from unittest.mock import mock_open @@ -25,19 +29,17 @@ def client(mock_store): return testing.TestClient(api) -def test_list_images(client): - doc = { - 'images': [ - { - 'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png', - }, - ], - } +def test_list_images(client, mock_store): + images = ['first-file', 'second-file', 'third-file'] + image_docs = [{'href': '/images/' + image} for image in images] + + mock_store.list.return_value = images response = client.simulate_get('/images') - result_doc = msgpack.unpackb(response.content, raw=False) - assert result_doc == doc + result = json.loads(response.content) + + assert result['images'] == image_docs assert response.status == falcon.HTTP_OK @@ -64,7 +66,7 @@ def test_post_image(client, mock_store): assert saver_call[0][1] == image_content_type -def test_saving_image(monkeypatch): +def test_saving_image(): # This still has some mocks, but they are more localized and do not # have to be monkey-patched into standard library modules (always a # risky business). @@ -84,3 +86,56 @@ def mock_uuidgen(): assert store.save(fake_request_stream, 'image/png') == fake_uuid + '.png' assert call().write(fake_image_bytes) in mock_file_open.mock_calls + + +def test_get_image(client, mock_store): + file_bytes = b'fake-image-bytes' + + mock_store.open.return_value = ((file_bytes,), 17) + + response = client.simulate_get('/images/filename.png') + + assert response.status == falcon.HTTP_OK + assert response.content == file_bytes + + +def test_opening_image(): + file_name = f'{uuid.uuid4()}.png' + storage_path = '.' + file_path = f'{storage_path}/{file_name}' + fake_image_bytes = b'fake-image-bytes' + with open(file_path, 'wb') as image_file: + file_length = image_file.write(fake_image_bytes) + + store = look.images.ImageStore(storage_path) + + file_reader, content_length = store.open(file_name) + assert content_length == file_length + assert file_reader.read() == fake_image_bytes + os.remove(file_path) + + with TestCase().assertRaises(IOError): + store.open('wrong_file_name_format') + + +def test_listing_images(): + file_names = [f'{uuid.uuid4()}.png' for _ in range(2)] + storage_path = '.' + file_paths = [f'{storage_path}/{name}' for name in file_names] + fake_images_bytes = [ + b'fake-image-bytes', # 17 + b'fake-image-bytes-with-more-length', # 34 + ] + for i in range(2): + with open(file_paths[i], 'wb') as image_file: + file_length = image_file.write(fake_images_bytes[i]) + + store = look.images.ImageStore(storage_path) + assert store.list(10) == [] + assert store.list(20) == [file_names[0]] + assert len(store.list(40)) == 2 + assert sorted(store.list(40)) == sorted(file_names) + + for file_path in file_paths: + os.remove(file_path) +