The Design of Shrine
If you want an in-depth walkthrough through the Shrine codebase, see 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::* | Manages files on a particular storage service |
Shrine | Wraps uploads and handles loading plugins |
Shrine::UploadedFile | Represents a file uploaded to a storage |
Shrine::Attacher | Handles file attachment logic |
Shrine::Attachment | 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