Creating a full-stack kanban board: the Ruby on Rails API backend

Posted on February 1, 2021

This is part 2 of a 2 part series, you can find the first part on creating the Vue frontend here: Creating a full-stack kanban board: the Vue SPA frontend

💡 Demo

The app we're creating is a full-stack kanban board where you can manage the cards with all CRUD (i.e. Create, Read, Update, Delete) operations and the cards will automatically update in the database when moving it between different columns. 

You can find a demo of it here: Demo Kanban Board

⚙️ Code

The example project's code can be found in these repositories on Github:

Vue frontend: https://github.com/WoetDev/woetflow-demo-kanban-board-vue 

Ruby on Rails backend: https://github.com/WoetDev/woetflow-demo-kanban-board-api



1. Setting up the Ruby on Rails API

1.1 Generating the Ruby on Rails project

We’ll be using Ruby on Rails as our API, with RoR we can get everything up and running extremely quickly and we’ll be able to generate the JSON responses with a minimal amount of code. 

Since we only need a simple API, we’ll exclude a lot of the default files and gems that are part of a standard new rails project. 

We’ll also setup a PostgreSQL database instead of the default SQLite3 database. Since I’ll be hosting the API on Heroku, the app will be running on PostgreSQL in production so it’s a good practice to be running on the same DBMS in development. 

To create the project, run: rails new woetflow-demo-kanban-board-api -T -C -M --skip-active-storage -d=postgresql --api

This what all the options mean that we’re adding to the rails new command:

  • -T → Skip test files

  • -C → Skip ActionCable files

  • -M → Skip ActionMailer files

  • --skip-active-storage → Skip ActiveStorage files

  • -d=postgresql →  Preconfigure for selected database: PostgreSQL

  • --api → Controllers will inherit from ActionController::API instead of ActionController::Base and generators will not create views.


1.2 Creating the PostgreSQL database

Now, we’ll create the database. I’m running the commands from a Linux Ubuntu terminal. In case you’re running on Mac some of these commands might differ. 

Create database user ‘name' with password

sudo -u postgres createuser -s name -P

Add database user password as an environment variable

echo 'export WOETFLOW_DEMO_KANBAN_BOARD_DATABASE_PASSWORD="PostgreSQL_Role_Password"' >> ~/.bashrc

Export the variable for current session

source ~/.bashrc

Add user configuration to database.yml

...
default: &default
  adapter: postgresql
  encoding: unicode
  # For details on connection pooling, see Rails configuration guide
  # https://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: name
  password: <%= ENV['WOETFLOW_DEMO_KANBAN_BOARD_DATABASE_PASSWORD'] %>

development:
  <<: *default
  database: woetflow_demo_kanban_board_api_development
...

Create databases from database.yml

rails db:create

If you might run into an error in the future with the database, the classic “have you tried turning it on & off again” usually helps me out. 

I do that with the command: 

sudo service postgresql restart

1.3 Add rack-cors gem

For our last step, we’ll also uncomment the rack-cors gem in the Gemfile, we’ll need this to make it possible to send our JSON responses from the API to our Vue app. 

Gemfile:

...
# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem 'rack-cors'
...

Run: bundle install

Now our project and database should be setup! To test if everything is working correctly, let’s boot up our server by running rails s and navigate to localhost:3000 in the browser. If you’re seeing the “Yay! You’re on Rails!” message then we’re good to go!

2. Add columns endpoint

Since the cards belong to a user and column, we’ll first set up these endpoints. We also don’t need to support all CRUD operations for the columns and users, so this will only require an index route.

Let’s start by creating the necessary model & controller.

2.1 Create the column model

To generate the model, run: rails g model column label value

Both the label and value columns will be strings, this is the default value for the model generator so we don’t need to specify the data type for these columns. If we’d want a column to have another value then we’d need to define it as for example rails g model column label:text value:integer

Once the generator is finished, run the migration: rails db:migrate

The columns will be associated with cards in a one-to-many relationship, so we’ll already set that up in our model. 

app/models/column.rb:

class Column < ApplicationRecord
  has_many :cards
end 

2.2 Create the columns controller

To generate the controller, run: rails g controller columns index

The generator will add the columns_controller.rb in app/controllers and the necessary route in config/routes.rb. 

For the routes, we’ll make a small adjustment so the index action routes to /columns instead of columns/index.

config/routes.rb:

Rails.application.routes.draw do
  get '/columns', to: 'columns#index'
end

Back to our columns_controller, we’ll add the logic of the index action. The index action will return a JSON with all the columns sorted by ids and will also contain the JSON for all the cards associated with that column sorted by updated_at. This way we can easily iterate the JSON in our Vue app and get all the columns and cards we need in one request. 

app/controllers/columns_controller.rb:

class ColumnsController < ApplicationController
  def index
    @columns = Column.order(:id)

    render json: @columns, include: { cards: { include: [ :user, :column ], except: [:user_id, :column_id] }}
  end
end

And that's all we need to do to set-up the columns controller, although this won’t work just yet before we’ve added our user and card models. 

3. Add users endpoint

For the users, we’ll do the same as for our columns. The only difference is that we won’t return any of the associations in the JSON.

3.1 Create the user model

Run: rails g model user label value

Run: rails db:migrate

And lastly, also set-up the association again in the model.

app/models/user.rb:

class User < ApplicationRecord
  has_many :cards
end

3.2 Create the users controller

Run: rails g controller users index

Once the generator has run, we’ll also make the same adjustment to our routes file.

config/routes.rb:

Rails.application.routes.draw do
  ...
  get '/users', to: 'users#index'
end

After that, we’ll make the index action return all users, sorted by their label (i.e. their name). 

app/controllers/users_controller.rb:

class UsersController < ApplicationController
  def index
    @users = User.all.order(:label)

    render json: @users
  end
end

4. Add cards endpoint

For the cards we need to support all CRUD operations, so we’ll set this up with a scaffold.

Run: rails g scaffold card title date:datetime tag column:references user:references

Run: rails db:migrate

4.1 Create the card model

For the model, our associations will already be added to by declaring column:references & user:references in the scaffolding command. 

All we still need to add to the model are validations for values that shouldn’t be blank. We also have client-side validations for this, but it’s important that these validations are aligned on the server-side.

app/models/card.rb:

class Card < ApplicationRecord
  ...
  validates :title, :date, :column_id, :user_id, presence: true
end

And that’s it for our card model. 

4.2 Create the cards controller

When going to the cards_controller.rb file, we’ll see that the scaffold has already created all the CRUD actions for us. 

Since we don’t show a detail of one specific card, we can delete the show action and prevent its route from being created.

app/controllers/cards_controller.rb:

class CardsController < ApplicationController
  before_action :set_card, only: [:show, :update, :destroy]
  ...
  # GET /cards/1
  def show
    render json: @card
  end
  ...
end

config/routes.rb:

Rails.application.routes.draw do
  resources :cards, except: [:show]
  ...
end

Once we’ve deleted the show action we just have one more thing to do in the controller. 

We’ll adjust the index, create and update actions so the JSON response also returns the full JSON body of the associations of the card instead of only the id.

app/controllers/cards_controller.rb:

class CardsController < ApplicationController
  before_action :set_card, only: [:update, :destroy]

  # GET /cards
  def index
    @cards = Card.all

    render json: @cards, include: [:user, :column], except: [:user_id, :column_id]
  end

  # POST /cards
  def create
    @card = Card.new(card_params)

    if @card.save
      render json: @card, include: [:user, :column], except: [:user_id, :column_id], status: :created, location: @card
    else
      render json: @card.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /cards/1
  def update
    if @card.update(card_params)
      render json: @card, include: [:user, :column], except: [:user_id, :column_id]
    else
      render json: @card.errors, status: :unprocessable_entity
    end
  end
  
  ...
end

Nice! Now all the controllers and models we need are set-up! We only need to do some last adjustments for our API to be able to properly send data over to our frontend and we’ll add sample data in the database to test everything out. 

5. Update CORS configuration

In order for our API to not block incoming requests from the frontend, we need to enable Cross Origin Resource Sharing (CORS). We already have the support we need in our API for this by uncommenting the rack-cors gem during our project setup. 

To enable it, we simply need to add the domain of our frontend to the cors initializer.

config/initializers/cors.rb:

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:8080'

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

6. Add seed data

And finally, let’s add sample data to our API and test if everything is working as expected. We’re gonna add sample data for our columns, users and cards in the seeds.rb file.

Once you’ve added the code to the seeds.rb file, you can run rails db:seed to add it to the database. If afterwards you would like to reset the values in the database with the seed file, you can run rails db:seed:replant.

To test if everything is working after adding the seeds, you can boot up the server with rails s

If you navigate to the paths /cards, /columns or /users on localhost, these should all return JSON responses now. 

To check out how your code should look at this point, you can refer to the repository in this commit

The seeds file is quite long so I’ll end the article here. If you haven’t set-up the Vue frontend of the application yet, you can hop back over to the other part to start or continue working on the board.

db/seeds.rb:

columns = [
  {
    label: "TO-DO",
    value: "to_do"
  },
  {
    label: "IN PROGRESS",
    value: "in_progress"
  },
  {
    label: "REVIEW",
    value: "review"
  },
  {
    label: "DONE",
    value: "done"
  }
]

users = [
  {
    label: "Steve Jobs",
    value: "https://pickaface.net/gallery/avatar/Opi51c74d0125fd4.png",
  },
  {
    label: "Wally De Backer",
    value: "https://pickaface.net/gallery/avatar/HalcyonicBlues51d76e22316fb.png"
  },
  {
    label: "Indro Warkop",
    value: "https://pickaface.net/gallery/avatar/technostar2651dbbe73a502d.png"
  },
  {
    label: "Vincent Chase",
    value: "https://pickaface.net/gallery/avatar/Opi51c74f6c56e40.png"
  },
  {
    label: "Johnny Drama",
    value: "https://pickaface.net/gallery/avatar/Opi51c74f056fc74.png"
  },
  {
    label: "Johnny Depp",
    value: "https://pickaface.net/gallery/avatar/gs315535348ce076c6.png"
  },
  {
    label: "Bashir Hamdok",
    value: "https://pickaface.net/gallery/avatar/unr_bashir_210120_0314_cvj3.png"
  }
]

columns.each do |column|
  Column.create(label: column[:label], value: column[:value])
end

users.each do |user|
  User.create(label: user[:label], value: user[:value])
end

cards = [
  {
    title: "Add discount code to checkout page",
    date: "2020-12-14",
    tag: "Feature Request",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Provide documentation on integrations",
    date: "2021-01-12",
    tag: "Backend",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Design shopping cart dropdown",
    date: "2021-01-09",
    tag: "Design",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Test checkout flow",
    date: "2021-09-15",
    tag: "QA",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Design wishlist overview",
    date: "2021-01-09",
    tag: "Design",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Add paypal as a payment provider",
    date: "2021-01-14",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Update documentation on products endpoint",
    date: "2021-01-19",
    tag: "Backend",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Design products carousel",
    date: "2021-01-10",
    tag: "Design",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Add related products section",
    date: "2021-01-14",
    tag: "Feature Request",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Design wishlist dropdown",
    date: "2021-01-09",
    tag: "Design",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Add new properties to products endpoint",
    date: "2021-01-14",
    tag: "Backend",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Prepare product meeting",
    date: "2021-01-14",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  },
  {
    title: "Design discount code for checkout page",
    date: "2021-01-12",
    tag: "Design",
    column_id: Column.find_by(value: columns.sample[:value])[:id],
    user_id: User.find_by(label: users.sample[:label])[:id]
  }
]

cards.each do |card|
  Card.create(title: card[:title], date: card[:date], tag: card[:tag], column_id: card[:column_id], user_id: card[:user_id])
end