Repository: saleor/saleor

Issue:
Add image thumbnails to ProductImage type
API should be able to return image thumbnails in specified size - something like this:
```
{
    product {
    images{
      edges{
        node{
          thumbnail(x:256, y:256)
        }
      }
    }
  }
}
```

This could help us avoid situations where API consumer gets 10 `4000px x 7000px` images just to put them in `120px x 120px` containers.


Discussion:
As we won't be able to provide an exact match for 99% of requests, I think this would make more sense:

```graphql
{
  node {
    thumbnail(width: 100, height: 100) {
      height
      width
      url
    }
  }
}
```

or maybe:

```graphql
{
  node {
    thumbnail(type: "product_gallery")
  }
}
```

Edit: In practice is it useful to specify height separately from width given that we're very unlikely to ever crop product images?
I probably didn't understand the feature scope - could You please explain what's the difference between the requested feature and existing possibility to specify the size of the image's url:
```
{
  products(first: 3) {
                edges {
                    node {
                        id
                        name
                        url
                        thumbnailUrl(size: "90x90")
                        images {
                            edges {
                                node {
                                    url(size: "120x120")
                                }
                            }
                        }
                    }
                }
  }
}
```

As @oldPadavan noticed, we already have this feature in our API. We should only change it to accept one of few predefined sizes. In `settings.py` we have `VERSATILEIMAGEFIELD_RENDITION_KEY_SETS` settings which defines what sizes are pre-generated for products, we could use that. The size argument should then be an enum.
I think `url(size: 120)` is easier (notice I've changed it to only accept one value) as otherwise the client has to know the mapping of names to sizes (which cannot be determined within the same query) and has to include a function mapping desired sizes to setting names. I think it's easy enough for the backend to do the matching (select equal size if exists, otherwise select closest larger size if not more than 2 times larger, otherwise select closest smaller size).
Does it mean it's necessary to restrict the sizes to those defined in `VERSATILEIMAGEFIELD_RENDITION_KEY_SETS`?
Yes, we do not want to allow API clients to create arbitrary sizes at will. A malicious user could easily generate terabytes worth of thumbnails by asking for all possible combinations of widths and heights.

Resolving patch:
diff --git a/saleor/graphql/product/types.py b/saleor/graphql/product/types.py
--- a/saleor/graphql/product/types.py
+++ b/saleor/graphql/product/types.py
@@ -3,7 +3,8 @@
 from graphene_django.filter import DjangoFilterConnectionField
 
 from ...product import models
-from ...product.templatetags.product_images import product_first_image
+from ...product.templatetags.product_images import (
+    get_thumbnail, product_first_image)
 from ...product.utils import products_with_details
 from ...product.utils.availability import get_availability
 from ...product.utils.costs import (
@@ -129,9 +130,7 @@ class Product(CountableDjangoObjectType):
         description='The storefront URL for the product.', required=True)
     thumbnail_url = graphene.String(
         description='The URL of a main thumbnail for a product.',
-        size=graphene.Argument(
-            graphene.String,
-            description='Size of a thumbnail, for example 255x255.'))
+        size=graphene.Argument(graphene.Int, description='Size of thumbnail'))
     availability = graphene.Field(
         ProductAvailability,
         description="""Informs about product's availability in the storefront,
@@ -154,7 +153,7 @@ class Meta:
 
     def resolve_thumbnail_url(self, info, *, size=None):
         if not size:
-            size = '255x255'
+            size = 255
         return product_first_image(self, size)
 
     def resolve_url(self, info):
@@ -264,8 +263,7 @@ class ProductImage(CountableDjangoObjectType):
     url = graphene.String(
         required=True,
         description='',
-        size=graphene.String(
-            description='Size of an image, for example 255x255.'))
+        size=graphene.Int(description='Size of image'))
 
     class Meta:
         description = 'Represents a product image.'
@@ -275,5 +273,5 @@ class Meta:
 
     def resolve_url(self, info, *, size=None):
         if size:
-            return self.image.crop[size].url
+            return get_thumbnail(self.image, size, 'crop')
         return self.image.url
diff --git a/saleor/product/templatetags/product_images.py b/saleor/product/templatetags/product_images.py
--- a/saleor/product/templatetags/product_images.py
+++ b/saleor/product/templatetags/product_images.py
@@ -45,26 +45,53 @@ def choose_placeholder(size=''):
     return placeholder
 
 
-@register.simple_tag()
-def get_thumbnail(instance, size, method='crop'):
-    size_name = '%s__%s' % (method, size)
+def get_available_sizes_by_method(method):
+    sizes = []
+    for available_size in AVAILABLE_SIZES:
+        available_method, avail_size_str = available_size.split('__')
+        if available_method == method:
+            sizes.append(min([int(s) for s in avail_size_str.split('x')]))
+    return sizes
+
+
+def get_thumbnail_size(size, method):
+    """ Return closest larger size if not more than 2 times larger, otherwise
+    return closest smaller size
+    """
     on_demand = settings.VERSATILEIMAGEFIELD_SETTINGS[
         'create_images_on_demand']
+    size_str = '%sx%s' % (size, size)
+    size_name = '%s__%s' % (method, size_str)
+    if size_name in AVAILABLE_SIZES or on_demand:
+        return size_str
+    avail_sizes = sorted(get_available_sizes_by_method(method))
+    larger = [x for x in avail_sizes if size < x <= size * 2]
+    smaller = [x for x in avail_sizes if x <= size]
+
+    if larger:
+        return '%sx%s' % (larger[0], larger[0])
+    elif smaller:
+        return'%sx%s' % (smaller[-1], smaller[-1])
+    msg = (
+        "Thumbnail size %s is not defined in settings "
+        "and it won't be generated automatically" % size_name)
+    warnings.warn(msg)
+    return None
+
+
+@register.simple_tag()
+def get_thumbnail(instance, size, method='crop'):
     if instance:
-        if size_name not in AVAILABLE_SIZES and not on_demand:
-            msg = (
-                "Thumbnail size %s is not defined in settings "
-                "and it won't be generated automatically" % size_name)
-            warnings.warn(msg)
+        used_size = get_thumbnail_size(size, method)
         try:
-            thumbnail = getattr(instance, method)[size]
+            thumbnail = getattr(instance, method)[used_size]
         except Exception:
             logger.exception(
                 'Thumbnail fetch failed',
                 extra={'instance': instance, 'size': size})
         else:
             return thumbnail.url
-    return static(choose_placeholder(size))
+    return static(choose_placeholder('%sx%s' % (size, size)))
 
 
 @register.simple_tag()


Test patch:
diff --git a/tests/api/test_graphql.py b/tests/api/test_graphql.py
--- a/tests/api/test_graphql.py
+++ b/tests/api/test_graphql.py
@@ -120,8 +120,8 @@ def test_real_query(user_api_client, product):
             ...ProductPriceFragmentQuery
             __typename
         }
-        thumbnailUrl1x: thumbnailUrl(size: "255x255")
-        thumbnailUrl2x: thumbnailUrl(size: "510x510")
+        thumbnailUrl1x: thumbnailUrl(size: 255)
+        thumbnailUrl2x: thumbnailUrl(size: 510)
         url
         __typename
     }
diff --git a/tests/test_product_tags.py b/tests/test_product_tags.py
--- a/tests/test_product_tags.py
+++ b/tests/test_product_tags.py
@@ -1,20 +1,23 @@
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
 
 from django.contrib.staticfiles.templatetags.staticfiles import static
+from django.test import override_settings
 
 from saleor.product.templatetags.product_images import (
     choose_placeholder, get_thumbnail, product_first_image)
 
 
+@override_settings(
+    VERSATILEIMAGEFIELD_SETTINGS={'create_images_on_demand': True})
 def test_get_thumbnail():
     instance = Mock()
     cropped_value = Mock(url='crop.jpg')
     thumbnail_value = Mock(url='thumb.jpg')
     instance.crop = {'10x10': cropped_value}
     instance.thumbnail = {'10x10': thumbnail_value}
-    cropped = get_thumbnail(instance, '10x10', method='crop')
+    cropped = get_thumbnail(instance, 10, method='crop')
     assert cropped == cropped_value.url
-    thumb = get_thumbnail(instance, '10x10', method='thumbnail')
+    thumb = get_thumbnail(instance, 10, method='thumbnail')
     assert thumb == thumbnail_value.url
 
 
@@ -22,10 +25,54 @@ def test_get_thumbnail_no_instance(monkeypatch):
     monkeypatch.setattr(
         'saleor.product.templatetags.product_images.choose_placeholder',
         lambda x: 'placeholder')
-    output = get_thumbnail(instance=None, size='10x10', method='crop')
+    output = get_thumbnail(instance=None, size=10, method='crop')
     assert output == static('placeholder')
 
 
+@patch(
+    'saleor.product.templatetags.product_images.AVAILABLE_SIZES',
+    {
+        'thumbnail__800x800', 'crop__100x100', 'crop__1000x1000',
+        'crop__2000x2000'})
+@override_settings(
+    VERSATILEIMAGEFIELD_SETTINGS={'create_images_on_demand': False})
+def test_get_thumbnail_to_larger():
+    instance = Mock()
+    cropped_value = Mock(url='crop.jpg')
+    instance.crop = {'1000x1000': cropped_value}
+    cropped = get_thumbnail(instance, 800, method='crop')
+    assert cropped == cropped_value.url
+
+
+@patch(
+    'saleor.product.templatetags.product_images.AVAILABLE_SIZES',
+    {'crop__10x10', 'crop__100x100', 'crop__1000x1000', 'crop__2000x2000'})
+@override_settings(
+    VERSATILEIMAGEFIELD_SETTINGS={'create_images_on_demand': False})
+def test_get_thumbnail_to_smaller():
+    instance = Mock()
+    cropped_value = Mock(url='crop.jpg')
+    instance.crop = {'100x100': cropped_value}
+    cropped = get_thumbnail(instance, 400, method='crop')
+    assert cropped == cropped_value.url
+
+
+@patch(
+    'saleor.product.templatetags.product_images.AVAILABLE_SIZES',
+    {'thumbnail__800x800'})
+@override_settings(
+    VERSATILEIMAGEFIELD_SETTINGS={'create_images_on_demand': False},
+    PLACEHOLDER_IMAGES={1080: 'images/placeholder1080x1080.png'})
+def test_get_thumbnail_no_match_by_method():
+    instance = Mock()
+    cropped_value = Mock(url='crop.jpg')
+    instance.crop = {'1000x1000': cropped_value}
+    cropped = get_thumbnail(instance, 800, method='crop')
+    assert cropped == static('images/placeholder1080x1080.png')
+
+
+@override_settings(
+    VERSATILEIMAGEFIELD_SETTINGS={'create_images_on_demand': True})
 def test_product_first_image():
     mock_product_image = Mock()
     mock_product_image.image = Mock()
@@ -34,7 +81,7 @@ def test_product_first_image():
     mock_queryset = Mock()
     mock_queryset.all.return_value = [mock_product_image]
     mock_product = Mock(images=mock_queryset)
-    out = product_first_image(mock_product, '10x10', method='crop')
+    out = product_first_image(mock_product, 10, method='crop')
     assert out == 'crop.jpg'