design.md

doc/design.md
Last Update: 2017-09-11 13:15:18 +0200

The Design of Shrine

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

  • Storage

  • Shrine

  • Shrine::UploadedFile

  • Shrine::Attacher

  • Shrine::Attachment

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 responds to certain methods:

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

      def open(id)
        # 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.

Shrine

A Shrine object (also called an “uploader”) acts as a wrapper around a storage. First the storage needs to be registered under a name:

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

Now we can instantiate an uploader with this identifier, and upload files:

uploader = Shrine.new(:file_system)
uploaded_file = uploader.upload(file)
uploaded_file #=> #<Shrine::UploadedFile>

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

  • closes the file

  • creates a Shrine::UploadedFile from the data

In applications it's common to create subclasses of Shrine, in order to allow having different uploading logic for different types of files.

Shrine::UploadedFile

Shrine::UploadedFile represents a file that was uploaded to a storage, and is the result of Shrine#upload. It is essentially a wrapper around a data hash containing information about the uploaded file.

uploaded_file      #=> #<Shrine::UploadedFile>
uploaded_file.data #=>
# {
#   "storage"  => "file_system",
#   "id"       => "9260ea09d8effd.pdf",
#   "metadata" => {
#     "filename"  => "resume.pdf",
#     "mime_type" => "application/pdf",
#     "size"      => 983294,
#   },
# }

The data hash contains the storage the file was uploaded to, the location, and some metadata: original filename, MIME type and filesize. The Shrine::UploadedFile object has handy methods which use this data:

# metadata methods
uploaded_file.original_filename
uploaded_file.mime_type
uploaded_file.size
# ...

# storage methods
uploaded_file.url
uploaded_file.exists?
uploaded_file.download
uploaded_file.delete
# ...

A Shrine::UploadedFile is itself an IO-like object (representing the remote file), 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 the responsibility of Shrine::Attacher. A Shrine::Attacher uses Shrine uploaders and Shrine::UploadedFile objects internally.

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 is instantiated with a model instance and an attachment name (an “image” attachment will be saved to image_data field):

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

attacher.assign(file)
attacher.get #=> #<Shrine::UploadedFile>
attacher.record.image_data #=> "{\"storage\":\"cache\",\"id\":\"9260ea09d8effd.jpg\",\"metadata\":{...}}"

attacher._promote
attacher.get #=> #<Shrine::UploadedFile>
attacher.record.image_data #=> "{\"storage\":\"store\",\"id\":\"ksdf02lr9sf3la.jpg\",\"metadata\":{...}}"

Above a file is assigned by the attacher, which “caches” (uploads) the file to the temporary storage. The cached file is then “promoted” (uploaded) to permanent storage. Behind the scenes a cached Shrine::UploadedFile is given to Shrine#upload, which works because Shrine::UploadedFile is an IO-like object. After both caching and promoting the data hash of the uploaded file is assigned to the record's column as JSON.

For more details see Using Attacher.

Shrine::Attachment

Shrine::Attachment is the highest level of abstraction. A Shrine::Attachment module exposes the Shrine::Attacher object through the model instance. The Shrine::Attachment class is a sublcass 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[:image]

We can include this module to a model:

class Photo
  include Shrine::Attachment.new(:image)
end
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>

When an ORM plugin is loaded, 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/removed or the record destroyed