design.md

doc/design.md
Last Update: 2023-07-30 18:26:06 +0200

title: The Design of Shrine

If you want an in-depth walkthrough through the Shrine codebase, see {Notes on study of shrine implementation}[https://bibwild.wordpress.com/2018/09/12/notes-on-study-of-shrine-implementation/] article by Jonathan Rochkind.

There are five main types of classes that you deal with in Shrine:

Class Description
[‘Shrine::Storage::*`](Storage) Manages files on a particular storage service
[‘Shrine`](Shrine) Wraps uploads and handles loading plugins
[‘Shrine::UploadedFile`](shrineuploadedfile) Represents a file uploaded to a storage
[‘Shrine::Attacher`](shrineattacher) Handles file attachment logic
[‘Shrine::Attachment`](shrineattachment) Provides convenience model attachment interface

Storage

On the lowest level we have a storage. A storage class encapsulates file management logic on a particular service. It is what actually performs uploads, generation of URLs, deletions and similar. By convention it is namespaced under Shrine::Storage::*.

filesystem = Shrine::Storage::FileSystem.new("uploads")
filesystem.upload(file, "foo")
filesystem.url("foo") #=> "uploads/foo"
filesystem.delete("foo")

A storage is a PORO which implements the following interface:

class Shrine
  module Storage
    class MyStorage
      def upload(io, id, shrine_metadata: {}, **upload_options)
        # uploads `io` to the location `id`
      end

      def open(id, **options)
        # returns the remote file as an IO-like object
      end

      def exists?(id)
        # checks if the file exists on the storage
      end

      def delete(id)
        # deletes the file from the storage
      end

      def url(id, **options)
        # URL to the remote file, accepts options for customizing the URL
      end
    end
  end
end

Storages are typically not used directly, but through {Shrine} and {Shrine::UploadedFile} classes.

Shrine

The Shrine class (also called an “uploader”) primarily provides a wrapper method around Storage#upload. First, the storage needs to be registered under a name:

Shrine.storages[:disk] = Shrine::Storage::FileSystem.new("uploads")

Now we can upload files to the registered storage:

uploaded_file = Shrine.upload(file, :disk)
uploaded_file #=> #<Shrine::UploadedFile storage=:disk id="6a9fb596cc554efb" ...>

The argument to Shrine#upload must be an IO-like object. The method does the following:

  • generates a unique location

  • extracts metadata

  • uploads the file (calls Storage#upload)

  • closes the file

  • creates a Shrine::UploadedFile from the data

Plugins

The Shrine class is also used for loading plugins, which provide additional functionality by extending core classes.

Shrine.plugin :derivatives

Shrine::UploadedFile.ancestors #=> [..., Shrine::Plugins::Derivatives::FileMethods, Shrine::UploadedFile::InstanceMethods, ...]
Shrine::Attacher.ancestors     #=> [..., Shrine::Plugins::Derivatives::AttacherMethods, Shrine::Attacher::InstanceMethods,  ...]
Shrine::Attachment.ancestors   #=> [..., Shrine::Plugins::Derivatives::AttachmentMethods, Shrine::Attachment::InstanceMethods, ...]

The plugins store their configuration in Shrine.opts:

Shrine.plugin :derivation_endpoint, secret_key: "foo"
Shrine.plugin :default_storage, store: :other_store
Shrine.plugin :activerecord

Shrine.opts #=>
# { derivation_endpoint: { options: { secret_key: "foo" }, derivations: {} },
#   default_storage: { store: :other_store },
#   column: { serializer: Shrine::Plugins::Column::JsonSerializer },
#   model: { cache: true },
#   activerecord: { callbacks: true, validations: true } }

Each Shrine subclass has its own copy of the core classes, storages and options, which makes it possible to customize attachment logic per uploader.

MyUploader = Class.new(Shrine)
MyUploader::UploadedFile.superclass #=> Shrine::UploadedFile
MyUploader::Attacher.superclass     #=> Shrine::Attacher
MyUploader::Attachment.superclass   #=> Shrine::Attachment

See Creating a New Plugin guide and the Plugin system of Sequel and Roda article for more details on the design of Shrine’s plugin system.

Shrine::UploadedFile

A Shrine::UploadedFile object represents a file that was uploaded to a storage, containing upload location, storage, and any metadata extracted during the upload.

uploaded_file #=> #<Shrine::UploadedFile id="949sdjg834.jpg" storage=:store metadata={...}>

uploaded_file.id          #=> "949sdjg834.jpg"
uploaded_file.storage_key #=> :store
uploaded_file.storage     #=> #<Shrine::Storage::S3>
uploaded_file.metadata    #=> {...}

It has convenience methods for accessing metadata:

uploaded_file.metadata #=>
# {
#   "filename" => "matrix.mp4",
#   "mime_type" => "video/mp4",
#   "size" => 345993,
# }

uploaded_file.original_filename #=> "matrix.mp4"
uploaded_file.extension         #=> "mp4"
uploaded_file.mime_type         #=> "video/mp4"
uploaded_file.size              #=> 345993

It also has methods that delegate to the storage:

uploaded_file.url                     #=> "https://my-bucket.s3.amazonaws.com/949sdjg834.jpg"
uploaded_file.open { |io| ... }       # opens the uploaded file stream
uploaded_file.download { |file| ... } # downloads the uploaded file to disk
uploaded_file.stream(destination)     # streams uploaded content into a writable destination
uploaded_file.exists?                 #=> true
uploaded_file.delete                  # deletes the uploaded file from the storage

A Shrine::UploadedFile is itself an IO-like object (built on top of Storage#open), so it can be passed to Shrine#upload as well.

Shrine::Attacher

We usually want to treat uploaded files as attachments to records, saving their data into a database column. This is done by Shrine::Attacher, which internally uses Shrine and Shrine::UploadedFile classes.

The attaching process requires a temporary and a permanent storage to be registered (by default that’s :cache and :store):

Shrine.storages = {
  cache: Shrine::Storage::FileSystem.new("uploads/cache"),
  store: Shrine::Storage::FileSystem.new("uploads/store"),
}

A Shrine::Attacher can be initialized standalone and handle the common attachment flow, which includes dirty tracking (promoting cached file to permanent storage, deleting previously attached file), validation, processing, serialization etc.

attacher = Shrine::Attacher.new

# ... user uploads a file ...

attacher.assign(io) # uploads to temporary storage
attacher.file       #=> #<Shrine::UploadedFile storage=:cache ...>

# ... handle file validations ...

attacher.finalize   # uploads to permanent storage
attacher.file       #=> #<Shrine::UploadedFile storage=:store ...>

It can also be initialized with a model instance to handle serialization into a model attribute:

attacher = Shrine::Attacher.from_model(photo, :image)

attacher.assign(file)
photo.image_data #=> "{\"storage\":\"cache\",\"id\":\"9260ea09d8effd.jpg\",\"metadata\":{...}}"

attacher.finalize
photo.image_data #=> "{\"storage\":\"store\",\"id\":\"ksdf02lr9sf3la.jpg\",\"metadata\":{...}}"

For more details, see the Using Attacher guide and {entity}/{model} plugins.

Shrine::Attachment

A Shrine::Attachment module provides a convenience model interface around the Shrine::Attacher object. The Shrine::Attachment class is a subclass of Module, which means that an instance of Shrine::Attachment is a module:

Shrine::Attachment.new(:image).is_a?(Module) #=> true
Shrine::Attachment.new(:image).instance_methods #=> [:image=, :image, :image_url, :image_attacher, ...]

# equivalents
Shrine::Attachment.new(:image)
Shrine::Attachment[:image]
Shrine::Attachment(:image)

We can include this module into a model:

Photo.include Shrine::Attachment(:image)
photo.image = file   # shorthand for `photo.image_attacher.assign(file)`
photo.image          # shorthand for `photo.image_attacher.get`
photo.image_url      # shorthand for `photo.image_attacher.url`

photo.image_attacher #=> #<Shrine::Attacher @cache_key=:cache @store_key=:store ...>

When a persistence plugin is loaded ({activerecord}, {sequel}), the Shrine::Attachment module also automatically:

  • syncs Shrine’s validation errors with the record

  • triggers promoting after record is saved

  • deletes the uploaded file if attachment was replaced or the record destroyed