Writing a Storage
Shrine ships with the FileSystem and S3 storages, but it's also easy to create
your own. A storage is a class which needs to implement #upload
, #url
,
#open
, #exists?
, and #delete
methods.
class Shrine
module Storage
class MyStorage
def upload(io, id, shrine_metadata: {}, **upload_options)
# uploads `io` to the location `id`, can accept upload options
end
def open(id, **options)
# returns the remote file as an IO-like object
end
def url(id, **options)
# returns URL to the remote file, can accept URL options
end
def exists?(id)
# returns whether the file exists on storage
end
def delete(id)
# deletes the file from the storage
end
end
end
end
Upload
The #upload
storage method is called by Shrine#upload
, it accepts an IO
object (io
) and upload location (id
) and is expected to upload the IO
content to the specified location. It's also given :shrine_metadata
that was
extracted from the IO, which can be used for specifying request headers on
upload. The storage can also support custom upload options (which can be
utilized with the upload_options
plugin).
class MyStorage
# ...
def upload(io, id, shrine_metadata: {}, **upload_options)
# uploads `io` to the location `id`, can accept upload options
end
# ...
end
Unless you're already using a Ruby SDK, it's recommended to use HTTP.rb for
uploading. It accepts any IO object that implements IO#read
(not just file
objects), and it streams the request body directly to the TCP socket, both for
raw and multipart uploads, making it suitable for large uploads.
require "http"
# streaming raw upload
HTTP.post("http://example.com/upload", body: io)
# streaming multipart upload
HTTP.post("http://example.com/upload", form: { file: HTTP::FormData::File.new(io) })
It's good practice to test the storage with a fake IO object which responds only to required methods, as not all received IO objects will be file objects.
If your storage doesn't control which id the uploaded file will have, you
can modify the id
variable before returning:
def upload(io, id, shrine_metadata: {}, **upload_options)
# ...
id.replace(actual_id)
end
Likewise, if you need to save some information into the metadata after upload (e.g. if the MIME type of the file changes on upload), you can modify the metadata hash:
def upload(io, id, shrine_metadata: {}, **upload_options)
# ...
shrine_metadata.merge!(returned_metadata)
end
Open
The #open
storage method is called by various Shrine::UploadedFile
methods
that retrieve uploaded file content. It accepts the file location and is
expected to return an IO-like object (that implements #read
, #size
,
#rewind
, #eof?
, and #close
) that represents the uploaded file.
class MyStorage
# ...
def open(id, **options)
# returns the remote file as an IO-like object
end
# ...
end
Ideally, the returned IO object should lazily retrieve uploaded content, so that in cases where metadata needs to be extracted from an uploaded file, only a small portion of the file will be downloaded.
It's recommended to use the Down gem for this. If the storage exposes its
files over HTTP, you can use Down.open
, otherwise if it's possible to stream
chunks of content from the storage, that can be wrapped in a Down::ChunkedIO
.
It's recommended to use the Down::Http
backend, as the HTTP.rb gem
allocates an order of magnitude less memory when reading the response body
compared to Net::HTTP
.
The storage can support additional options to customize how the file will be
opened, Shrine::UploadedFile#open
and Shrine::UploadedFile#download
will
forward any given options to #open
.
When file is not found, Shrine::FileNotFound
exception should be raised.
Url
The #url
storage method is called by Shrine::UploadedFile#url
, it accepts a
file location and is expected to return a resolvable URL to the uploaded file.
Custom URL options can be supported if needed, Shrine::UploadedFile#url
will
forward any given options to #url
.
class MyStorage
# ...
def url(id, **options)
# returns URL to the remote file, can accept URL options
end
# ...
end
If the storage does not have uploaded files accessible via HTTP, the #url
method should return nil
. Note that in this case users can use the
download_endpoint
or rack_response
plugins to create a downloadable link,
which are implemented in terms of #open
.
Exists
The #exists?
storage method is called by Shrine::UploadedFile#exists?
, it
accepts a file location and should return true
if the file exists on the
storage and false
otherwise.
class MyStorage
# ...
def exists?(id)
# returns whether the file exists on storage
end
# ...
end
Delete
The #delete
storage method is called by Shrine::UploadedFile#delete
, it
accepts a file location and is expected to delete the file from the storage.
class MyStorage
# ...
def delete(id)
# deletes the file from the storage
end
# ...
end
For convenience of use, this method should not raise an exception if the file doesn't exist.
Presign
If the storage service supports direct uploads, and requires fetching
additional information from the server, you can implement a #presign
method,
which will be called by the presign_endpoint
plugin. The #presign
method
should return a Hash with the following keys:
:method
– HTTP verb that should be used:url
– URL to which the file should be uploaded to:fields
– Hash of request parameters that should be used for the upload (optional):headers
– Hash of request headers that should be used for the upload (optional)
class MyStorage
# ...
def presign(id, **options)
# returns a Hash with :method, :url, :fields, and :headers keys
end
# ...
end
The storage can support additional options to customize how the presign will be
generated, those can be forwarded via the :presign_options
option on the
presign_endpoint
plugin.
Delete Prefixed and Clear
There are two methods that are not currently used by shrine, but which it's good for storages to provide to allow client code to delete files from storage. If storages provide these conventional methods, then clients can delete files using consistent API for any storage.
#clear!
deletes all files from storage, and #delete_prefixed
will delete all
files in a given directory/prefix/path. While not strictly required for shrine storage
service functionality, storages should usually implement if possible.
class MyStorage
# ...
def delete_prefixed(prefix_path)
# deletes all files under the supplied argument prefix
end
def clear!
# deletes all files in the storage
end
# ...
end
Update
If your storage supports updating data of existing files (e.g. some metadata),
the convention is to create an #update
method:
class MyStorage
# ...
def update(id, **options)
# update data of the file
end
# ...
end
Linter
To check that your storage implements all these methods correctly, you can use
Shrine::Storage::Linter
:
require "shrine/storage/linter"
storage = Shrine::Storage::MyStorage.new(*args)
linter = Shrine::Storage::Linter.new(storage)
linter.call
The linter will test your methods with fake IO objects, and raise a
Shrine::LintError
if any part of the contract isn't satisfied.
If you want to specify the IO object to use for testing (e.g. you need the IO to be an actual image), you can pass in a lambda which returns the IO when called:
linter.call(->{File.open("test/fixtures/image.jpg")})
If you don't want errors to be raised but rather only warnings, you can
pass action: :warn
when initializing
linter = Shrine::Storage::Linter.new(storage, action: :warn)
Note that using the linter doesn't mean that you shouldn't write any manual tests for your storage. There will likely be some edge cases that won't be tested by the linter.