Resources:
- Rails-5-2-Active-Storage-Redis-Cache-Store-HTTP2-Early-Hints-Credentials/
- rails-5-2-active-storage-and-beyond
- Pains of managing cloud storage providers, authentication, and data operations (an extremely common use case)
- Multiple packages for handling this existed in the form of Gems, many of which worked perfectly well, it was still a forced choice, forced configuration and external dependency in a system designed around the guiding principle of Convention over Configuration
“Active Storage was extracted from Basecamp 3 by George Claghorn and yours truly. So not only is the framework already used in production, it was born from production. There’s that Extraction Design guarantee stamp alright!”
– DHH
Checkout the Acitve Storage Git
A key difference to how Active Storage works compared to other attachment solutions in Rails is through the use of built-in models (backed by Active Record):
- Active storage blobs have data about the file.
- Active storage attachments associate records with blobs.
This means existing application models do not need to be modified with additional columns to associate with files. Active Storage uses polymorphic associations via the Attachment join model, which then connects to the actual Blob.
Blob models store attachment metadata (filename, content-type, etc.), and their identifier key in the storage service. Blob models do not store the actual binary data. They are intended to be immutable in spirit. One file, one blob. You can associate the same blob with multiple application models as well. And if you want to do transformations of a given Blob, the idea is that you'll simply create a new one, rather than attempt to mutate the existing one (though of course you can delete the previous version later if you don't need it).
I'm confused where is my file?
Not in the db. It's on a cloud service... that's kind of the whole point. However, during development, so long as your config/storage.yml
is set up to use the disk service locally, you're file will be stored in the ./storage
directory or .tmp/storage/
for testing. (An example config/storage.yml
file can be found in the next section)
local:
service: Disk
root: <%= Rails.root.join("storage") %>
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
amazon:
service: S3
access_key_id: ENV['AWS_ACCESS_KEY']
secret_access_key: ENV['AWS_SECRET_ACCESS_KEY']
bucket: 'some-bucket-name'
region: 'us-east-1'
google:
service: GCS
credentials: <%= Rails.root.join("path/to/keyfile.json") %>
project: "google-project"
bucket: "gcs-bucket"
These tables in db/schema.rb
are set up for you in new projects. Run rails active_storage:install
to copy over their migrations.
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.bigint "byte_size", null: false
t.string "checksum", null: false
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
This in your model file (ex. uses ./models/lesson.rb
where Model is Lesson and name of the attachment is video.
class Lesson < ApplicationRecord
# Associates an attachment and a blob. When the user is destroyed they are
# purged by default (models destroyed, and resource files deleted).
has_one_attached :video
end
class Lesson < ApplicationRecord
# Associates an attachment and a blob. When the user is destroyed they are
# purged by default (models destroyed, and resource files deleted).
has_many_attached :video
end
@lesson.video.attach(lesson_params[:video])
class LessonsController < ApplicationController
def create
@lesson = Lesson.create!(lesson_params)
redirect_to @lesson
end
def update
@lesson = Lesson.find(lesson_params[:lesson])
@lesson.update!(lesson_params)
redirect_to @lesson
end
private
def lesson_params
params.require(:lesson).permit(:video, :title)
end
end
@lesson.videos.attach(params[:videos])
class LessonsController < ApplicationController
def create
lesson = Lesson.create!(lesson_params)
redirect_to lesson
end
private
def lesson_params
params.require(:lesson).permit(:title, videos: [])
end
end
- Wrap in form-data object
// LessonVideosForm component
// example submit function called to upload file
submit = (lesson, videos) => {
const formData = new FormData();
videos.forEach(video => formData.append('payload[]', video));
formData.append('files', true);
LessonsAPI(lesson).post(formData)
.then(videos => {
videos.forEach(video => someSuccessFunction(video))
this.close()})
.catch(error => someFailFunction(error))
}
- Watch out for JSON.stringify in POST request
- Watch out for POST request with the content-type header set
// RequestWrapper component
// example post function
post: (url, data, options = { headers: {}}) => {
let body;
if (data instanceof FormData && data.has("files")) {
body = data
} else {
options.headers["Content-Type"] = "application/json"
body = JSON.stringify(data);
}
return fetch(url, {
method: "POST",
body: body,
...options
})
}