Rails 5.2 comes with a new module - Active Storage, which manage file storage in the framework. It supports local storage service, AWS, Azure and GCP by default with the ability to add user defined storage service. In this example, we will use AWS S3.

(1) Create a new rails 5.2 API Project.

(2) Install Active Storage. This will copy "storage.yml" and some DB migration files to your project.

rails active_storage:install

(3) Add S3 gem for connection to AWS S3.

gem 'aws-sdk-s3', require: false

(4) Add mini_magick gem to handle image files.

gem 'mini_magick', '~> 4.8'
bundle install

(5) Install graphicmagick library which is required by mini_magick.

(Mac)

brew uninstall imagemagick graphicsmagick libpng jpeg
brew cleanup -s

brew install graphicsmagick

(Ubuntu)

sudo apt-get install python-software-properties
sudo apt-get install software-properties-common
sudo add-apt-repository ppa:rwky/graphicsmagick
sudo apt-get update
sudo apt-get install graphicsmagick

(6) Open "storage.yml" to configure AWS setting.

amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
  region: ap-southeast-1
  bucket: your-bucket-999

(7) The access key id and secret can be configured in Rails encrypted credential file.

EDITOR="pico" rails credentials:edit

(8) Set your AWS credentials as follow and save the file.

aws:
  access_key_id: 123
  secret_access_key: 456

(9) In your environment file, set the type of storage service you want to use in each environment. E.g. development.rb

config.active_storage.service = :amazon

(10) Create a model with attachment.

class Product < ApplicationRecord
  has_one_attached :product_image
end

(11) Run DB migration. This will migrate Active Storage migrations as well as your own migrations.

rails db:migrate

(12) There are several ways to add attachment:

# From a file or URL
product.product_image.attach(
  io: File.open(Rails.root.join('path', 'to', 'product.png')),
  filename: 'product.png',
  content_type: 'image/png'
)
# From incoming request
product = Product.create(product_params)

def product_params
  params.require(:product).permit(:product_image)
end

(13) To check if a file is attached:

product.product_image.attached?

(14) To return a downloadable URL via API endpoint.

# In product.rb we add a method to return a signed URL of the attachment.
class Product < ApplicationRecord
  has_one_attached :product_image

  def product_image_url
    Rails.application.routes.url_helpers.url_for(product_image) if product_image.attached?
  end
end
# In environment (e.g. development.rb) we need to speify the default_url_options required by url_for
routes.default_url_options = { host: 'localhost', port: 3000, protocol: 'http' }
# We can return the URL in products_controller.rb
def index
  render json: Product.order(id: :asc).to_json(methods: %i[product_image_url]), status: :ok
end

(15) The client will get an URL pointing to your app, e.g.:

http://localhost:3000/rails/active_storage/blob/{blob-id}/product.png

(16) When this URL is triggered, it generates and redirects client to a signed S3 public URL that expires in 5 minutes.

(17) The route "/rails/active_storage" is defined in the internal routes.rb of Active Storage. So if you have a catch all route in your application level routes.rb, it will not match the routes defined in the internal version. To overcome this, you need to exclude this specific route. E.g.

match '*path', to: 'application#render_no_route', via: :all, constraints: lambda { |req|
  req.path.exclude? 'rails/active_storage' # Exclude this
}