An uploader is a small struct that ties together everything Latch needs
to know about a particular kind of file: where it should be stored, what
metadata to extract from it, and how it should be processed. You typically
define one per attachment type, like AvatarUploader, InvoicePdfUploader,
ProductImageUploader, and so on.
The minimum is a struct that includes Latch::Uploader:
# src/uploaders/avatar_uploader.cr
struct AvatarUploader
include Latch::Uploader
end
Lucky apps don’t generate src/uploaders by default. Create the
directory and add a require line to src/app.cr so the compiler picks
it up. It must come before ./models/**, because your models will
reference the uploader’s StoredFile type:
# src/app.cr
require "./shards"
require "../config/server"
require "./app_database"
require "../config/**"
require "./models/base_model"
require "./uploaders/**" # <- add this before models
require "./models/**"
# ...
Including Latch::Uploader does three things:
StoredFile subclass nested inside the uploader, e.g.
AvatarUploader::StoredFile. This is the type you store on your
models.filename, mime_type,
and size. You can read these directly off any stored file."cache" and "store" storages from your
config/latch.cr.Once defined, you can move files through the uploader manually:
# Cache: temporary storage, e.g. between form submissions
cached = AvatarUploader.cache(uploaded_file)
# Promote a cached file to permanent storage
stored = AvatarUploader.promote(cached)
# Or store directly, skipping the cache stage
stored = AvatarUploader.store(uploaded_file)
In a Lucky app you’ll rarely need to call these methods yourself. The Avram integration takes care of caching and promotion as part of the save lifecycle. They’re useful when you need to ingest files outside of an Avram operation, for example from a background job that pulls data from an external API.
By default, Latch generates a unique location for every upload using the
path_prefix from config/latch.cr and a random ID. Override
generate_location if you want full control over where files end up:
struct AvatarUploader
include Latch::Uploader
def generate_location(uploaded_file, metadata, **options) : String
date = Time.utc.to_s("%Y/%m/%d")
File.join("avatars", date, super)
end
end
Calling super keeps the default unique-ID behaviour, so you only have to
add the parts you care about.
The default "cache" and "store" keys cover most apps, but you might
want certain files to live in a different storage. For example, sensitive
documents in a private S3 bucket while images go to a public CDN. The
storages macro lets each uploader pick its own keys:
struct InvoiceUploader
include Latch::Uploader
storages cache: "tmp", store: "private"
end
Both keys must exist in Latch.settings.storages.
Every uploader produces its own StoredFile subclass when files are
cached, promoted, or stored. A StoredFile is a JSON-serialisable value
object (that’s why you can put it in a database column), but it also has a
handful of convenience methods for working with the underlying file:
stored.url # storage URL
stored.exists? # check existence
stored.extension # file extension, e.g. ".jpg"
stored.delete # remove from storage
stored.open { |io| io.gets_to_end } # read content
stored.download { |tempfile| tempfile.path } # download to a tempfile
stored.stream(response.output) # stream to any IO
Each uploader’s StoredFile is a real class, so you can reopen it and add
methods that make sense for your domain:
struct AvatarUploader
include Latch::Uploader
extract dimensions, using: Latch::Extractor::DimensionsFromMagick
class StoredFile
def aspect_ratio : Float64
width.to_f / height
end
def landscape? : Bool
aspect_ratio > 1
end
end
end
user.avatar.try(&.aspect_ratio)
The methods are scoped to that uploader’s stored files, so different uploaders can have different helpers without bleeding into one another.
Once your uploader is in place, the next thing to teach it is what to learn from incoming files. Continue to Extracting Metadata.