File Uploads with Gemma
Preview ecosystem guide: Gemma is not part of the Amber 2.0.0-beta.5 core web-app release gate. Its package version, API, and platform support may change independently. Confirm a compatible official release before adding it to an application.
Gemma is a file attachment toolkit for Crystal applications, inspired by Shrine for Ruby. It connects model attachments to validation, temporary uploads, permanent storage, and delivery across configurable backends.
Authored assets and uploads are different lifecycles
Use Asset Pipeline for files that ship with an application release: CSS, JavaScript, logos, interface images, fonts, icons, and other reviewed static files. Those files can be content-addressed, included in a release manifest, and cached immutably because the deployment owns their bytes.
Use Gemma for files received while the application is running. An upload is untrusted input and may be private, replaced, or deleted. Do not copy uploads into the Asset Pipeline source tree, add them to its manifest, or assume its immutable cache policy applies. Validate the file, store it outside the application release artifact, and choose an authenticated controller response, a presigned object-storage URL, or an explicitly configured public-upload route for delivery.
Where the examples go
- Add dependencies in
shard.ymland run commands from the application root. - Configure Gemma in
config/uploads.cr. The generated application entry point loads top-levelconfig/*files before application source. - Attachment declarations belong in Grant models under
src/models/. - Upload handling belongs in the receiving controller under
src/controllers/; form and display markup belongs in the matching ECR file undersrc/views/.
Blocks on this page use those destinations unless a closer label says otherwise.
Why Gemma?
- Storage Agnostic - Switch between filesystem and S3 without changing application code
- Grant Integration - First-class support for Grant ORM with
has_one_attachedandhas_many_attached - Validation Support - Built-in validators for file size, content type, and dimensions
- Plugin System - Add MIME type detection and metadata extraction
- Two-Stage Uploads - Cache files temporarily, then promote to permanent storage
Installation
File: shard.yml — add Gemma under the existing dependencies: key.
dependencies:
gemma:
github: amberframework/gemma
version: ~> 0.6.5
Run shards install from the application root.
Quick Start
1. Configure Storage
File: config/uploads.cr — create this complete storage configuration. Do not
put it in the generated empty config/initializers/ directory unless you also
add and verify an explicit require.
require "gemma"
Gemma.configure do |config|
# Temporary storage for uploads in progress
config.storages["cache"] = Gemma::Storage::FileSystem.new(
"uploads",
prefix: "cache"
)
# Permanent storage for completed uploads
config.storages["store"] = Gemma::Storage::FileSystem.new("uploads")
end
File: the application entry point, for example src/my_app.cr — retain
require "../config/*" before controllers and models. A migrated app with a
narrower require list must explicitly require ../config/uploads; creating the
file alone does not load it.
This example stores files under project-root uploads/. Amber's generated
static route serves public/; it does not make project-root uploads/
public. Keep private uploads there and deliver them through an authorized
application endpoint or object storage. If the product deliberately uses public
local uploads, configure a dedicated persistent directory and route, and test
the returned Gemma URL before rendering it in a view.
2. Add Attachment to Model
File: src/models/user.cr — keep the attachment declaration inside User.
require "gemma/grant"
class User < Grant::Base
include Gemma::Grant::Attachable
column id : Int64, primary: true
column name : String
column avatar_data : JSON::Any?
has_one_attached :avatar
end
3. Use in Controller
File: src/controllers/users_controller.cr — add this behavior inside the
action that receives the upload.
class UsersController < ApplicationController
def create
user = User.new(user_params)
# Assign uploaded file
if file = params.files["avatar"]?
user.avatar = file.file
end
if user.save
redirect_to "/users/#{user.id}"
else
render "users/new.ecr"
end
end
end
4. Display in View
File: src/views/users/show.ecr — render the attachment inside the user page.
<% if user.avatar %>
<img src="<%= user.avatar_url %>" alt="Avatar">
<% end %>
This view assumes avatar_url resolves through the delivery path selected
above. Request that URL directly during verification; a URL-shaped value alone
does not prove that Amber can serve the stored file.
How It Works
Gemma uses a two-stage upload process:
- Cache Stage - Files are first uploaded to temporary "cache" storage
- Store Stage - On model save, cached files are promoted to permanent "store" storage
This approach provides several benefits:
- Failed validations don't leave orphaned files
- Users can preview uploads before final submission
- Background processing can happen between stages
# Behind the scenes
user.avatar = uploaded_file # Uploaded to cache
user.save # Promoted to store
Core Concepts
UploadedFile
Represents an uploaded file with metadata:
uploaded_file = user.avatar
uploaded_file.id # => "abc123.jpg"
uploaded_file.url # => "/uploads/abc123.jpg"
uploaded_file.size # => 12345
uploaded_file.mime_type # => "image/jpeg"
uploaded_file.original_filename # => "photo.jpg"
uploaded_file.extension # => "jpg"
uploaded_file.exists? # => true
# Access raw IO
uploaded_file.open do |io|
# Process file content
end
# Download to tempfile
uploaded_file.download do |tempfile|
# Work with local file
end
Storages
Gemma supports multiple storage backends:
| Storage | Use Case |
|---|---|
FileSystem |
Local development, simple deployments |
S3 |
Production, cloud deployments |
Memory |
Testing |
Attacher
The internal mechanism that manages file attachment lifecycle:
attacher = user._avatar_attacher
attacher.file # Current file
attacher.cached? # File in temporary storage?
attacher.stored? # File in permanent storage?
attacher.changed? # File was modified?
attacher.url # File URL
Features
Single File Attachments
class User < Grant::Base
include Gemma::Grant::Attachable
column avatar_data : JSON::Any?
has_one_attached :avatar
end
user.avatar = File.open("photo.jpg")
user.save
user.avatar_url # => "/uploads/abc123.jpg"
Multiple File Attachments
class Post < Grant::Base
include Gemma::Grant::Attachable
column images_data : JSON::Any?
has_many_attached :images
end
post.images = [File.open("img1.jpg"), File.open("img2.jpg")]
post.save
post.images.each do |image|
puts image.url
end
# Add single file
post.add_image(File.open("img3.jpg"))
# Remove file
post.remove_image(post.images.first)
# Clear all
post.clear_images
Custom Uploaders
Create custom uploaders for specialized handling:
class ImageUploader < Gemma
def generate_location(io, metadata, context, **options)
name = super(io, metadata, **options)
# Organize by model and ID
File.join(
context[:model].class.name.underscore,
context[:model].id.to_s,
name
)
end
end
# Use custom uploader
has_one_attached :avatar, uploader: ImageUploader
Next Steps
- Attachments - Single and multiple file attachments
- Storage Backends - Configure FileSystem and S3
- Validation - Validate file uploads