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"
}
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"
}
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
after_save happens in transaction, before the model is persisted to DBafter_commit happens after transaction and after model has been persistedLet'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