Client/Server helpers for dynamic image fetching

By Ryan Romanchuk
On

AWS Serverless Image Handler allows any caller to request a known image, applying transformations and edits, on the fly. The only requirement is knowing the source's bucket and key path. Offload image manipulation tasks from a traditional backend, unblocking other teams as requirements change.

An important caveat in this approach is the potential risk of miss-use, resulting in significant decline in cache hits as your cache key is dependent on construction of the base64 encoded edits. Enforce convention by obfuscating url construction and offer traditional pre-planned presets.

Backend

Usage examples:

# app/models/user.rb
class User < ApplicationRecord
  include Imageable
end
<%# app/views/users/show.html.erb %>
<h1>My Profile Images</h1>
<p> 200x200 Image</p>
<img src="<%= user.dynamic_square_image %>">
<p>25x24</p>
<img src="<%= user.dynamic_tiny_square_image %>">
<p>Default placeholder/no image</p>
<img src="<%= user.dynamic_placeholder_image %>">
<p>Original</p>
<img src="<%= user.default_dynamic_image %>">
# app/models/concerns/imageable.rb
module Imageable
  extend ActiveSupport::Concern

  IMAGE_ENDPOINT = Rails.configuration.general.images_endpoint

  def dynamic_image(with_edits: {})
    return nil if image_key.nil?

    IMAGE_ENDPOINT + encoded_path(edits: with_edits)
  end

  def default_dynamic_image
    @default_dynamic_image ||= dynamic_image
  end

  def dynamic_placeholder_image
    @dynamic_placeholder_image ||= IMAGE_ENDPOINT + encoded_path(edits: { key: placeholder_image_key,
                                                                          bucket: placeholder_image_source_bucket })
  end

  def placeholder_image_key
    'static/user-empty-profile.jpg'
  end

  def placeholder_image_source_bucket
    'my-static-assets'
  end

  def dynamic_square_image
    @dynamic_square_image ||= dynamic_image(with_edits: dynamic_preset_small_square)
  end

  def dynamic_tiny_square_image
    @dynamic_tiny_square_image ||= dynamic_image(with_edits: dynamic_preset_small_tiny)
  end

  private

  # These presets should match the client presets if possible
  # @see DynamicImage.swift
  def encoded_path(edits: {})
    Base64.urlsafe_encode64 request_body.merge(edits).sort.to_h.to_json
  end

  def request_body
    {
      bucket: image_source_bucket,
      key: image_key
    }
  end

  def dynamic_preset_small_square
    {
      edits: {
        resize: {
          width: 200,
          height: 200,
          fit: 'cover'
        }
      }
    }
  end

  def dynamic_preset_small_tiny
    {
      edits: {
        resize: {
          width: 25,
          height: 25,
          fit: 'cover'
        }
      }
    }
  end
end
iOS Client

Usage examples

class Video: Object {
    @Persisted var image_key: String?
    @Persisted var image_source_bucket: String?

   var indicatorThumbnail: String? {
        guard let image_key = image_key, let image_source_bucket = image_source_bucket else { return nil }
        return DynamicImage.preset_200x200(imageKey: image_key, imageBucket: image_source_bucket).url
    }
}

let thumbnail = Video().indicatorThumbnail
// or 
let thumbnail = DynamicImage.preset_200x200(imageKey: 'cool.jpg', imageBucket: 'profile-phots').url
// or
let originalImage = DynamicImage.original(imageKey: 'cool.jpg', imageBucket: 'profile-phots').url
enum DynamicImage {
    case original(imageKey: String, imageBucket: String)
    case square(imageKey: String, imageBucket: String)
    case preset_200x200(imageKey: String, imageBucket: String)

    var url: String? {
        switch self {
        case let .original(imageKey, imageBucket):
            let request: Parameters = ["bucket": imageBucket, "key": imageKey]
            return buildUrl(request: request)
        case let .square(imageKey, imageBucket):
            let request: Parameters = ["bucket": imageBucket, "key": imageKey, "edits": edits]
            return buildUrl(request: request)
        case let .preset_200x200(imageKey, imageBucket):
            let request: Parameters = ["bucket": imageBucket, "key": imageKey, "edits": edits]
            return buildUrl(request: request)
        }
    }

    private var edits: Parameters {
        switch self {
        case .preset_200x200:
            return ["resize": resize]
        case .square:
            return ["resize": resize]
        default:
            return Parameters()
        }
    }

    private var resize: Parameters {
        switch self {
        case .preset_200x200:
            return ["width": 200, "height": 200, "fit": "cover"]
        case .square:
            return ["width": 200, "height": 200, "fit": "cover"]
        default:
            return Parameters()
        }
    }

    private func buildUrl(request: Parameters) -> String? {
        guard let encodedPath = try? JSONSerialization
            .data(withJSONObject: request, options: .sortedKeys)
            .base64EncodedString() else {
                return nil
        }

        return "\(C.Web.config.home)/image/\(encodedPath)"
    }
}
talk