This is a hands-on Rails Intro tutorial.

Provided steps should allow you to follow along and test everything by yourself

Be proactive, be curious. Try doing everything, and ask questions.

Open up the terminal ant execute the following command:

rails new academy-demo \
  --api \
  --skip-test \
  --skip-action-mailer \
  --skip-action-mailbox \
  --skip-active-storage 

If all goes well, you should see output similar to this:

      remove  config/initializers/content_security_policy.rb
      remove  config/initializers/permissions_policy.rb
      remove  config/initializers/new_framework_defaults_7_0.rb
         run  bundle install
Fetching gem metadata from https://rubygems.org/...........
Resolving dependencies...
Bundle complete! 6 Gemfile dependencies, 56 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
         run  bundle binstubs bundler

Now cd into the generated folder which contains our API project

cd academy-demo

You can now open the project in your favorite editor

Previously we skipped tests with rails (it uses test-unit), so now it's time to add rspec

Open up the Gemfile and add gem 'rspec-rails' and add under development, test section:

group :development, :test do
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "debug", platforms: %i[ mri mingw x64_mingw ]
  gem 'rspec-rails'
end

Now install the gem itself

bundle install

And bootstrap the rspec and set it as a test runner:

rails generate rspec:install

Now it's time for your first model. Let's create our User model.

In the project directory execute:

rails g model User username:string email:string

And these files should be created

      invoke  active_record
      create    db/migrate/<timestamp>_create_users.rb
      create    app/models/user.rb
      invoke    rspec
      create      spec/models/user_spec.rb

We know have a database migration generated, which we should apply:

rails db:migrate RAILS_ENV=development
rails db:migrate RAILS_ENV=test

You should get an output similar to this. Table has been created. Note the plural vs singular model naming

== 20240508064016 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0011s
== 20240508064016 CreateUsers: migrated (0.0011s) =============================

open up spec/models/user_spec.rb file and lets add our first test

Replace the file contents with the following:

require 'rails_helper'

RSpec.describe User do
  describe '#new' do
    subject { described_class.new }
    
    it 'creates a valid user' do
      expect(subject).to be_a(User)
    end
  end
end

Now let's run bundle that first test:

bundle exec rspec spec/models/user_spec.rb

You can also run all tests

bundle exec rspec 

Not much use of the data, if you cannot expose it. So let's add our first controller

rails g controller Users index show create 

You should see output similar to this:

      create  app/controllers/users_controller.rb
       route  get 'users/index'
              get 'users/show'
              get 'users/create'
      invoke  rspec
      create    spec/requests/users_spec.rb

Now it's time to actually check if rails are working

fire up the rails server:

rails server

or a shorthand:

rails s

you should see the following output:

=> Booting Puma
=> Rails 7.0.8.1 application starting in development 
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.6.8 (ruby 3.1.3-p185) ("Birdie's Version")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 15060
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop

Hit the browser and check if it works http://127.0.0.1:3000

Let's inspect the config/routes.rb file:

Rails.application.routes.draw do
  get 'users/index'
  get 'users/show'
  get 'users/create'
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  # root "articles#index"
end

And let's make it more Rubyish, replace it's content with:

Rails.application.routes.draw do
  resources :users, only: %i(index show create)
end

Rails routing is quite complex and easy to get wrong, but there is a nice documentation Rails Routing from the Outside In

Now let's check if those routes are actually valid, execute on the console:

rails routes

Which should give you the following output:

Prefix Verb URI Pattern          Controller#Action
 users GET  /users(.:format)     users#index
       POST /users(.:format)     users#create
  user GET  /users/:id(.:format) users#show

And if it actually works, let's hit it with curl:

curl -v localhost:3000/users

And you should get the following output:

*   Trying [::1]:3000...
* Connected to localhost (::1) port 3000
> GET /users HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/1.1 204 No Content
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 0
< X-Content-Type-Options: nosniff
< X-Download-Options: noopen
< X-Permitted-Cross-Domain-Policies: none
< Referrer-Policy: strict-origin-when-cross-origin
< Cache-Control: no-cache
< X-Request-Id: a57a7f81-0c6b-4725-bce6-d06d7ed99499
< X-Runtime: 0.001458
< Server-Timing: start_processing.action_controller;dur=0.05, process_action.action_controller;dur=0.10
<
* Connection #0 to host localhost left intact

Let's open up app/controllers/users_controller.rb and implement the #index or get all endpoint:

  def index
    users = User.all

    render json: users
  end

Now let's restart the rails serve and see if something changed. Let's execute curl again

curl localhost:3000/users

Which now should return an empty array

➜  academy-demo git:(main) ✗ curl localhost:3000/users
[]%
➜  academy-demo git:(main) ✗

So we've got our model, our controller but for now it's pretty useless

Let's add some data. Fire up rails console

rails console

Let's actually check that we have no data, enter the following command and hit enter:

User.all

You should see which query was executed, and that there are no records

irb(main):001> User.all
  User Load (0.3ms)  SELECT "users".* FROM "users"
=> []

Now let's create some records

User.create!(username: 'jane', email:'jane@example.com')
User.create!(username: 'joe', email:'joe@example.com')

We should see records being created:

  TRANSACTION (0.0ms)  begin transaction
  User Create (0.4ms)  INSERT INTO "users" ("username", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["username", "jane"], ["email", "jane@example.com"], ["created_at", "2024-05-08 10:42:58.496943"], ["updated_at", "2024-05-08 10:42:58.496943"]]
  TRANSACTION (0.7ms)  commit transaction
=> 
#<User:0x000000010a626dc0
 id: 1,
 username: "jane",
 email: "jane@example.com",
 created_at: Wed, 08 May 2024 10:42:58.496943000 UTC +00:00,
 updated_at: Wed, 08 May 2024 10:42:58.496943000 UTC +00:00>

Let's check how many users we have:

User.all.length
irb(main):005> User.all.length
  User Load (0.1ms)  SELECT "users".* FROM "users"
=> 2

Now let's check if they are returned from our controller:

curl -s localhost:3000/users | jq

NOTE: If jq fails, you can add it via brew brew install jq

And you should get pretty output:

[
  {
    "id": 1,
    "username": "jane",
    "email": "jane@example.com",
    "created_at": "2024-05-08T10:42:58.496Z",
    "updated_at": "2024-05-08T10:42:58.496Z"
  },
  {
    "id": 2,
    "username": "joe",
    "email": "joe@example.com",
    "created_at": "2024-05-08T10:44:57.347Z",
    "updated_at": "2024-05-08T10:44:57.347Z"
  }
]

We can now get all users, which is great. But can we get a single user? Let's change our show endpoint

  def show
    user = User.find(params[:id])

    render json: user
  end

And see if it works:

curl -s localhost:3000/users/1 | jq

Which should give you a similar output:

{
  "id": 1,
  "username": "jane",
  "email": "jane@example.com",
  "created_at": "2024-05-08T10:42:58.496Z",
  "updated_at": "2024-05-08T10:42:58.496Z"
}

Bonus part:

What if you specify non existing id?

curl -s localhost:3000/users/1000 | jq

Creating records through console is fine, but what about real life?

Let's implement a method that allows us to create our user via api.

Replace the create with the following code

  def create
    user = User.create!(username: params[:username], email: params[:email])

    render json: user
  end

And check if it works:

curl -X POST -H "Content-Type: application/json" \
-d '{"username": "janedoe", "email": "janedoe@example.com"}' \
http://localhost:3000/users

you should get the following output

{"id":3,"username":"janedoe","email":"janedoe@example.com","created_at":"2024-05-08T11:27:18.815Z","updated_at":"2024-05-08T11:27:18.815Z"}%

Now we have our user id, let's see if we can retrieve it?

curl -s localhost:3000/users/3 | jq

Which should give you a similar output:

{
  "id": 3,
  "username": "janedoe",
  "email": "janedoe@example.com",
  "created_at": "2024-05-08T11:27:18.815Z",
  "updated_at": "2024-05-08T11:27:18.815Z"
}

Bonus part

Can we do better with user creation? Let's see if this work, replace the code and test creation again:

user = User.create!(params.to_h)

And check if it works

curl -X POST -H "Content-Type: application/json" \
-d '{"username": "johndoe", "email": "johndoe@example.com"}' \
http://localhost:3000/users

So how do we solve that ActionController::UnfilteredParameters?

Let's user param permitting

user = User.create!(params.permit(:username, :email).to_h)

And retest if creation works:

curl -X POST -H "Content-Type: application/json" \
-d '{"username": "johndoe", "email": "johndoe@example.com"}' \
http://localhost:3000/users

So we've limited what params you can pass in, what about what we expose? If we come back to our example, getting the user gives out quite some data:

curl -s localhost:3000/users/3 | jq

Which should give you a similar output:

{
  "id": 3,
  "username": "janedoe",
  "email": "janedoe@example.com",
  "created_at": "2024-05-08T11:27:18.815Z",
  "updated_at": "2024-05-08T11:27:18.815Z"
}

Is there a way to limit that?

Rails has a feature called serializers just for that.

Let's add a gem for that:

gem 'active_model_serializers', '~> 0.10'

do a bundle install:

bundle install

Create an initializer file config/initializers/activemodel_serializers.rb:

ActiveModelSerializers.config.adapter = :attributes

Let's create a file under app/serializers/user_serializer.rb with the following contents:

class UserSerializer < ActiveModel::Serializer
  attributes :username, :email
end

And let's recheck what is going on:

curl -s localhost:3000/users/3 | jq

We should get just the params that we have permitted:

{
  "username": "janedoe",
  "email": "janedoe@example.com"
}

So our controller got quite big, but it has no tests :(

Let's fix that

Let's add an file at spec/controllers/users_controller_spec.rb with the following contents:

require 'rails_helper'

RSpec.describe UsersController do 
  describe '#index' do
    subject { get :index }
    it 'returns users' do
      expect(subject).to be_successful
    end
  end
end

And check if the tests pass:

bundle exec rspec spec/controllers/users_controller_spec.rb

They do, but currently they don't provide much value - there are no users so response just succeeds

How do I create users to test if they are returned from the controller? I could create them manually:

let!(:user) { User.create!(username: 'test', email: 'test@example.com') }

And then test if they are returned:

    expect(response.body).to eq([
      username: user.username,
      email: user.email,
    ].to_json)

So we've managed to create our user manually in the tests, but can this be improved? Looks like a lot of repetition in each test

let!(:user) { User.create!(username: 'test', email: 'test@example.com') }

We can do better. Let's a gem called factory_bot

group :development, :test do
  gem "debug", platforms: %i[ mri mingw x64_mingw ]
  gem 'rspec-rails'
  gem 'factory_bot_rails'
end

Install the gem

bundle install

Add factory config to spec/spec_helper.rb Include it at the top:

require 'factory_bot_rails'

And on line 17 enable it

<...>
RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
<...>

Now let's create our first factory for the User model:

rails g factory_bot:model User username email

And inspect the generated spec/factories/users.rb

FactoryBot.define do
  factory :user do
    username { "MyString" }
    email { "MyString" }
  end
end

While we have a nice template, username and email set to "MyString" don't make much sense. Let's improve that:

FactoryBot.define do
  factory :user do
    sequence(:username) { |n| "user#{n}#{Time.now.to_f}" }
    sequence(:email) { |n| "test-#{n}-@example.com" }
  end
end

Now with each new user instance in tests we will have nice random unique values.

Let's actually test if this works. Open up spec/controllers/users_controller_spec.rb and replace how we create our user in tests:

let!(:user) { create(:user) }

And verify if it actually works:

bundle exec rspec spec/controllers/users_controller_spec.rb

So we have our User model, but it feels kind of lonely and useless. Let's add another one - Item

rails g model Item title:string description:string image_url:string user:belongs_to

Which should generate your model, test, and even factory file:

      invoke  active_record
      create    db/migrate/<timestamphere>_create_items.rb
      create    app/models/item.rb
      invoke    rspec
      create      spec/models/item_spec.rb
      invoke      factory_bot
      create        spec/factories/items.rb

Inspect the created files, and run the migration to apply database changes

rails db:migrate RAILS_ENV=development
rails db:migrate RAILS_ENV=test

If you open up the app/models/item.rb You already see it linked to the user:

belongs_to :user

Let's also link User to the item with has_many relationship. Open up app/models/user.rb and add the following line:

  has_many :items

So now the file should look like this:

  class User < ApplicationRecord
    has_many :items
  end

Great! But what does this all mean? Let's fire up the rails console and test it:

rails c

Let's get all items for existing user:

user = User.find(1)
user.items

This should give you 0 items:

irb(main):001> user = User.find(1)
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User:0x000000010f1aff08
...
irb(main):002> user.items
  Item Load (0.2ms)  SELECT "items".* FROM "items" WHERE "items"."user_id" = ?  [["user_id", 1]]
=> []
irb(main):003> 

Let's add a new item which belongs to user

user.items.create!(
  title: 'A very nice T-shirt',
  description: 'T-shirt, barely worn', 
  image_url:'https://picsum.photos/200/300'
)

And now let's check if that item actually exist on the user:

User.find(1).items

Which should give you similar output:

irb(main):009> User.find(1).items
  User Load (1.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Item Load (0.1ms)  SELECT "items".* FROM "items" WHERE "items"."user_id" = ?  [["user_id", 1]]
=> 
[#<Item:0x000000010edac370
  id: 1,
  title: "A very nice T-shirt",
  description: "T-shirt, barely worn",
  image_url: "https://picsum.photos/200/300",
  user_id: 1,
  <...>

So now we can create our user and add some items. What about actually exposing items?

Ideally we would create items controllers (but I'll leave that as an exercise on your own).

Let's instead see how we can expose items on user model.

If you fetch user by id, just the user will be returned:

curl -v localhost:3000/users/1

Let's change that. Open up app/serializers/user_serializer.rb and add the following lines:

has_many :items

And retest the getting of the user. This should return user together with items:

{
  "username": "jane",
  "email": "jane@example.org",
  "items": [
    {
      "id": 1,
      "title": "A very nice T-shirt",
      "description": "T-shirt, barely worn",
      "image_url": "https://picsum.photos/200/300",
      "user_id": 1,
      "created_at": "2024-05-09T12:56:13.237Z",
      "updated_at": "2024-05-09T12:56:13.237Z"
    }
  ]
}

Another advanced option that we can expose some derived attributes. Let's expose a new property item_count:

  def total_items
    object.items.length
  end

And add it to the attributes list:

attributes :username, :email, :total_items

Let's see if this new property is returned via API:

curl -v localhost:3000/users/1

Which should give similar output:

{
  "username": "jane",
  "email": "jane@example.org",
  "total_items": 1,
  "items": [
    <...>
  ]
}

What if I want to extend my exiting model? For that we use database migrations

Let's add an active flag on our user model. First let's create our migration file:

rails g migration AddActiveColumnToUser 

You should see output similar to this:

      invoke  active_record
      create    db/migrate/<timestamp here>_add_active_column_to_user.rb

now let's open up our generated migration file, and add migration logic. Just this time let's use pure SQL syntax:

  class AddActiveColumnToUser < ActiveRecord::Migration[7.0]
    def change
      execute <<~SQL
        ALTER TABLE users
        ADD COLUMN active tinyint(1) NOT NULL DEFAULT 1;
      SQL
    end
  end

And run migrations

  rails db:migrate RAILS_ENV=test
  rails db:migrate RAILS_ENV=development

Now your database should contain new column, and after restarting rails server it should be automatically picked up.

So now that we have our active column, what are we going to do with it?

Let's make sure that we only return active users when requested.

For that let's a feature called scope to our model. Open up app/models/user.rband add the folling line:

scope :active, -> { where(active: true) }

And let's see if it works. Fire up rails console and check if we can still find our user:

User.active.find(1)

Our user should still be there.

Now let's use this in our app/controllers/users_controller.rb:

User.active.all
User.active.find(1)

Now since we have everything in place. Let's remove one of our users:

user = User.find(1)
user.update!(active: false)

Restart the rails server, and check what the endpoint returns

curl -s localhost:3000/users/1 | jq

Though on user we have our email and username fields, we can actually have them empty.

Let's fix that

Add the following lines to app/model/user.rb

validates :email, :username, presence: true

and then try the following create options:

User.create!
User.create

We can also make validation conditional, e.g. only if the user is active

validates :username, :email, presence: true, if: :active?

Now let's see if we can have a blank user when it's inactive:

User.create!(active: false)

And you should see use being saved successfully:

  TRANSACTION (0.0ms)  begin transaction
  User Create (0.4ms)  INSERT INTO "users" ("username", "email", "created_at", "updated_at", "active") VALUES (?, ?, ?, ?, ?)  [["username", nil], ["email", nil], ["created_at", "2024-05-10 05:19:26.975196"], ["updated_at", "2024-05-10 05:19:26.975196"], ["active", 0]]
  TRANSACTION (0.9ms)  commit transaction
=> #<User:0x00000001059dff98 id: 1, username: nil, email: nil, created_at: Fri, 10 May 2024 05:19:26.975196000 UTC +00:00, updated_at: Fri, 10 May 2024 05:19:26.975196000 UTC +00:00, active: 0>

Rails models have multiple hooks, the two most interesting ones are after_save and after_commit

Let's try adding one of those hooks:

  after_save :send_to_moderation

  def send_to_moderation
    puts "New user added, will send for moderation #{email}"
  end

And test it by invoking create:

User.create!(email: 'test@example.com', username: 'test')

You should see a line printed out:

  TRANSACTION (0.0ms)  begin transaction
  User Create (0.3ms)  INSERT INTO "users" ("username", "email", "created_at", "updated_at", "active") VALUES (?, ?, ?, ?, ?)  [["username", "test"], ["email", "test@example.com"], ["created_at", "2024-05-10 05:40:44.530042"], ["updated_at", "2024-05-10 05:40:44.530042"], ["active", 1]]
New user added, will send for moderation test@example.com
  TRANSACTION (0.5ms)  commit transaction