Rails 6 and React integration with Active Storage image file attachments

In this post, I’ll show how to integrate a Rails 6 API with a React component to upload file attachments to Active Storage.

Part 1: Rails API

Initial project setup

rvm current
# ruby-2.5.7@rails6-active-storage-react

# create new rails project with a postgresql database
rails new . --api -d postgresql

# create and migrate the database
rake db:create && rake db:migrate

I added additional gems to the Gemfile

gem 'rack-cors'
gem 'image_processing'

group :development, :test do
  gem 'factory_bot_rails'
  gem 'pry'
  gem 'rspec-rails'
end

group :test do
  gem 'database_cleaner-active_record'
end

Install the gems via: bundle install

I added a basic CORS configuration to the file: config/initializers/cors.rb. This allows the React frontend to make API requests.

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins %w[localhost:3001]

    resource '*',
      headers: :any,
      methods: %i[get post put patch delete options head]
  end
end

I executed rails active_storage:install and rake db:migrate to create the necessary database migrations for Active Storage.

I added a migration to create a pictures table, and executed rake db:migrate.

class CreatePictures < ActiveRecord::Migration[6.0]
  def change
    create_table :pictures do |t|
      t.timestamps
    end
  end
end

I added the Picture model (file: app/models/picture.rb). The model implements methods for JSON serialization and defines a single Active Storage attachment. The JSON contains an attachment_url with a resized [200, 200] variant.

class Picture < ApplicationRecord
  include ActiveModel::Serializers::JSON

  has_one_attached :attachment

  def attributes
    {
      'id' => nil,
      'updated_at' => nil,
      'created_at' => nil,
      'attachment_url' => nil
    }
  end

  def attachment_url
    Rails.application.routes.url_helpers.rails_representation_url(
      attachment.variant(resize_to_limit: [200, 200]).processed, only_path: true
    )
  end
end

The controller (file: app/controllers/pictures_controller.rb) implements the index and create methods.

class PicturesController < ApplicationController
  def index
    render json: Picture.all.with_attached_attachment.order(id: :desc)
  end

  def create
    picture = Picture.new(picture_params)

    if picture.save
      render json: picture, status: :created
    else
      render json: picture.errors, status: :unprocessable_entity
    end
  end

  private

  def picture_params
    params.require(:picture).permit(:attachment)
  end
end

Last I added the picture controller routes to the file: config/routes.rb

Rails.application.routes.draw do
  resources :pictures, only: %i[create index]
end

Part 2: Testing

From the console I entered a directory containing test images to upload.

# upload all the images using curl
ls -1 | xargs -I{} curl -X POST -F "picture[attachment]=@./{}" http://localhost:3000/pictures

# fetching images from the index route
curl http://localhost:3000/pictures 2>/dev/null | jq '.[0]'
{
  "id": 68,
  "updated_at": "2020-02-15T13:15:24.465Z",
  "created_at": "2020-02-15T13:15:24.449Z",
  "attachment_url": "/rails/active_storage/representations/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBTUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--6229a61847a498801a17c0f72e5528239fcbc1ec/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9VY21WemFYcGxYM1J2WDJ4cGJXbDBXd2RwQWNocEFjZz0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--9747cbda9b013ecaed6d2f3f5323a132d671fc88/yM55sxm.jpg"
}

Next I setup RSpec for unit tests. I executed rails generate rspec:install to generate the configuration files.

I added a DatabaseCleaner strategy and included FactoryBot methods in the file: spec/rails_helper.rb

ENV['RAILS_ENV'] = 'test'

RSpec.configure do |config|
  # ...snip...
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end
  config.around(:each) do |example|
    DatabaseCleaner.cleaning do
      example.run
    end
  end

  config.include FactoryBot::Syntax::Methods
end

I added a FactoryBot factory for the picture model, file: spec/factories/pictures.rb, and copied the Rails logo into spec/fixtures/files/

FactoryBot.define do
  factory :picture do
    created_at { DateTime.now }
    updated_at { DateTime.now }

    trait :with_attachment do
      after :build do |picture|
        file_name = 'rails-logo.png'
        file_path = Rails.root.join('spec', 'fixtures', 'files', file_name)
        picture.attachment.attach(io: File.open(file_path), filename: file_name, content_type: 'image/png')
      end
    end
  end
end

Here is a sample controller test, file: spec/controllers/pictures_controller_spec.rb

require 'rails_helper'

RSpec.describe PicturesController, type: :controller do
  describe 'GET #index' do
    let!(:picture) { create(:picture, :with_attachment) }

    it 'is successful' do
      get :index
      expect(response).to be_successful
      response_body = JSON.parse(response.body)
      expect(response_body[0]['attachment_url']).to be_present
    end
  end

  describe 'POST #create' do
    let(:file_upload) { fixture_file_upload(file_fixture('rails-logo.png'), 'image/png') }

    it 'is successful' do
      post :create, params: { picture: { attachment: file_upload } }
      expect(response.status).to eq(201)
    end
  end
end

I executed rspec to ensure the tests run successfully.

Part 3: React front end

I created a new React project.

# set nodejs version using NVM
nvm use v12.15.0

# create react app
npx create-react-app .

# define nodejs version
nvm current > .nvmrc

# add bootstrap library for layout
npm install react-bootstrap bootstrap

I included the bootstrap CSS, file: src/index.js

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorker from './serviceWorker'

import 'bootstrap/dist/css/bootstrap.min.css'

ReactDOM.render(<App />, document.getElementById('root'))

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister()

I added a constants file to define the API host URL, new file: src/constants.js

export const ApiHost = 'http://localhost:3000'

I revised the main App component to include my the Pictures component, file: src/App.js

import React from 'react'
import Pictures from './Pictures'
import './App.css'

function App() {
  return (
    <div className="container">
      <Pictures></Pictures>
    </div>
  )
}

export default App

I created a basic Pictures component (file: src/Pictures.js). On mount, is loads the existing pictures from the API and renders them in a defined number of columns. It also provides a file input which submits (on change) to create a new picture via the API.

import React from 'react'
import { ApiHost } from './constants'

class Pictures extends React.Component {
  constructor(props) {
    super(props)

    this.state = {
      pictures: [],
      number_columns: 4,
      loading: true,
    }

    this.handleFileInputChange = this.handleFileInputChange.bind(this)
    this.loadPictures = this.loadPictures.bind(this)
  }

  render() {
    if (this.state.loading) return null

    return(
      <div className="pictures_container">

        <div className="row">
          <div className="col">
            <form>
              <div className="form-group">
                <label htmlFor="file_upload">Upload Picture</label>
                <input type="file" className="form-control-file" id="file_upload" onChange={this.handleFileInputChange} />
              </div>
            </form>
          </div>
        </div>

        {this.pictureRows().map((pictureRow, rowIndex) =>
          <div key={`picture_row_${rowIndex}`} className="row">
            {pictureRow.map((picture, columnIndex) =>
              <div key={`picture_row_${rowIndex}_col_${columnIndex}`} className="col-sm-3">
                <img data-id={picture.id} src={`${ApiHost}${picture.attachment_url}`} />
              </div>
            )}
          </div>
        )}
      </div>
    )
  }

  componentDidMount() {
    this.loadPictures()
  }

  loadPictures() {
    fetch(`${ApiHost}/pictures.json`)
      .then((response) => response.json())
      .then((pictures) =>
        this.setState({
          pictures: pictures,
          loading: false
        })
      )
  }

  handleFileInputChange(event) {
    let body = new FormData()
    body.append('picture[attachment]', event.target.files[0] )
    fetch(
      `${ApiHost}/pictures.json`,
      {
        method: 'post',
        body: body
      }
    )
    .then((response) => response.json())
    .then((picture) => {
      let pictures = this.state.pictures
      pictures.unshift(picture)
      this.setState({
        pictures: pictures
      })
    })
  }

  pictureRows() {
    let rows = []
    let row = []
    this.state.pictures.forEach((picture) => {
      row.push(picture)
      if (row.length === this.state.number_columns) {
        rows.push(row)
        row = []
      }
    })
    if (row.length > 0) {
      rows.push(row)
    }
    return rows
  }
}

export default Pictures

Last I added a bit of CSS to improve the pictures layout, file: src/App.css

.pictures_container img {
  max-height: 100%;
  max-width: 100%;
}

.pictures_container .row {
  margin-bottom: 30px;
}

The React front end can be started via: npm start.

A screenshot: rails6 react pictures

Updated: