Setup
Create a new project with the following line. The --api tells rails that we don't want views and the -T that we don't want the standard testing tool, called Minitest.
rails new library --api -T
Rails will create a folder structure with some boilerplate files and something called a Gemfile. It is the file that handles the dependencies that is needed. There's also a Gemfile.lock which specifies exactly which version of the dependencies that should be installed.
Since we removed the standard test framework, we must add another. Here we choose RSpec by adding these lines to the Gemfile:
gem 'rspec-rails'
We continue by running 'bundle install', this command will install the added dependencies. RSpec then has a special command for initiating things: 'rails generate rspec:install', this is documented here: https://github.com/rspec/rspec-rails
Generation and migration
Rails has commands for generating boilerplate code:
rails generate model Book title author isbn page_count:integer # Gives a model, a migration and a spec file
rails generate resource Book title:string author isbn page_count:integer # Same as model, but also an empty controller and a route
rails generate scaffold Book title author isbn page_count:integer # Same as resource, but the controller includes methods
String is the default type for new attributes, only page_count need to has its type (integer) defined.
New rails projects comes preconfigured with a SQLite database, so run `rake db:migrate` to add the model to the database.
Oops, we forgot one thing. when working with books, it great to look them up by ISBN. Let's add an index to the ISBN field.
rails generate migration AddIndexToIsbn
This will create a new empty migration, called AddIndexToIsbn, prepended with todays date. Populate the file as follows:
class AddIndexToIsbn < ActiveRecord::Migration[5.2]
def change
add_index :books, :isbn
end
end
Run 'rake db:migrate' to add the changes to the database.
Models and validation
Let's add stuff to our Book model:
class Book < ApplicationRecord
validates :author, :title, :isbn, presence: true
validates :isbn, uniqueness: true
def to_s
"#{title} by #{author}"
end
end
The validates method might look weird on first site, and it is! First of all, in Ruby, it's alright to leave out the parenthesis on nearly all occasions. The validates method is also a shortcut method that calls other validator methods. For example, the first validates will call the presence validator on author, title and isbn. The second will call the uniqueness validator on the isbn.
The to_s method, defined above, is easier to explain. to_s means 'to string' and is a method that all objects has. We choose to overwrite the standard to_s here, and print the title of the book, followed by 'by' and then the authors name. String interpolation is made with "" och #{}.
Rails console
Here we have a killer feature. Enter 'rails console' in the terminal and you are given a shell! This shell is inside the rails application, which makes it really powerful. Here is some basic things you can do:
irb(main):009:0> 1+1
=> 2
irb(main):011:0> 'hello'.upcase
=> "HELLO"
irb(main):012:0> [3,4,2,5,6,7,4,5].sort
=> [2, 3, 4, 4, 5, 5, 6, 7]
irb(main):014:0> 'Hello ' << 'world'
=> "Hello world"
But as I told you, you are really inside of the application. That means you can play around with the newly created book model.
# Create a new book ('create' will save to the database directly)
irb(main):001:0> Book.create(author: 'Will Writer', title: 'My first book', isbn: '3345353-65', page_count: 129)
=> #<Book id: 1, title: "My first book", author: "Will Writer", isbn: "3345353-65", page_count: 129, created_at: "2020-11-19 12:16:25", updated_at: "2020-11-19 12:16:25">
# Count how many books that exists in the database
irb(main):002:0> Book.count
=> 1
# Fetch the first book and execute the to_s method
irb(main):003:0> Book.first.to_s
=> "My first book by Will Writer"
# Make a new book ('new' will not save to database)
irb(main):004:0> b = Book.new(author: 'T Typewriter', title: 'Murders in Mordor')
=> #<Book id: nil, title: "Murders in Mordor", author: "T Typewriter", isbn: nil, page_count: nil, created_at: nil, updated_at: nil>
# Check if it is valid
irb(main):005:0> b.valid?
=> false
# Add ISBN to make it valid (ISBN must be set and unique)
irb(main):006:0> b.isbn = '3252324-6523'
=> "3252324-6523"
irb(main):007:0> b.valid?
=> true
# Save it to the database
irb(main):008:0> b.save
=> true
It's possible to do pretty much anything with the rails console. Call services, fix things in production, trigger jobs and so on. It has really saved me a couple of times.
Testing
No application is complete without tests. The test framework we chose calls them specs, but it's all the same. The generation commands created a book_spec.rb, let's fill it with specs.
require 'rails_helper'
RSpec.describe Book, type: :model do
it 'prints correct information on to_s' do
b = Book.new(author: 'Penny Pen', title: 'Life is Short', isbn: '213465234-235434')
expect(b.to_s).to eq("#{b.title} by #{b.author}")
end
it 'validates the presence of the title' do
b = Book.new(author: 'Penny Pen', isbn: '213465234-235434')
expect(b.valid?).to be_falsey
expect(b.errors.messages[:title]).to eq(['can\'t be blank'])
end
end
The first spec will check that the to_s method prints what we want. The second spec will make sure that a book without a title is not valid. Writing specs like the second one for every validation and attribute of the model would be a hassle, so let me introduce you to the gem shoulda-matchers. Add the following line to your Gemfile and run 'bundle install'.
gem 'shoulda-matchers'
Shoulda-matchers allows us to write shorthand tests to test validation. Before we can use it, we need to tell the testing framework to include it. In the rails_helper.rb, a file with test framework configuration, add the following lines at the bottom:
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
Now we can add these line to our book_spec.rb:
it { should validate_presence_of(:title) }
it { should validate_presence_of(:author) }
it { should validate_presence_of(:isbn) }
it { should validate_uniqueness_of(:isbn) }
Run the specs with the command 'rspec' in the terminal. Hopefully they all pass.
Two other gems that will facilitate testing is the Fabrication and Faker gems. Add the following to the Gemfile and then bundle install.
gem 'fabrication'
gem 'faker'
Create a folder spec/fabricators/. In it, create a file books_fabricator.rb and populate it like this:
require 'faker'
Fabricator(:book) do
title { Faker::Book.title }
author { Faker::Book.author }
isbn { Faker::Code.isbn }
page_count { rand(500) }
end
The Fabrication gem helps with dummy data creation. If you want a Book to use in a test, you can now write Fabricate(:book) to create a book and save it to the database. Fabricate.build(:book) to create without persisting. You can also do Fabricate.times(3, :book) to get an array with three books.
In the book_fabricator file, we specify what we want to include in our fabricated books. Here we use the Faker gem to fake more realistic data. The Faker gem has methods for book titles and authors, but there are so much more available too. Names, addresses, zipcodes, beer brands, buzz words, movie quotes and so on. You will never have to create a book called 'asfsdff' by author 'sfdsfsdfasdad' again.
We change the book spec to use the book fabricator:
it 'prints correct information on to_s' do
b = Fabricate.build(:book)
expect(b.to_s).to eq("#{b.title} by #{b.author}")
end
Controllers
A typical book controller would look like the following code.
class BooksController < ApplicationController
def show
book = Book.find(params[:id])
render json: book
end
def index
render json: Book.all
end
def create
book = Book.new(book_params)
if book.save
render json: book, status: :created
else
render json: { error: :invalid_parameters, message: book.errors.messages, fields: book.errors.keys }, status: 422
end
end
private
def book_params
params.require(:book).permit(:title, :author, :isbn)
end
end
Show, index and create corresponds to the routes GET /books/:id, GET /books and POST /books if the following record is added to the routes.rb. The resource keyword automatically expands into the mentioned patterns, but routes can be specified individually too.
resources :books
Specs for the controller would look like this:
require 'rails_helper'
RSpec.describe BooksController, type: :controller do
context 'get show' do
before(:each) do
@book = Fabricate(:book)
get :show, params: { id: @book.id }
end
it 'has status code 200' do
expect(response.code.to_i).to eq(200)
end
it 'returns book' do
json = JSON.parse(response.body)
expect(json['title']).to eq(@book.title)
expect(json['author']).to eq(@book.author)
end
end
context 'get index' do
before(:each) do
Fabricate.times(5, :book)
get :index
end
it 'has status code 200' do
expect(response.code.to_i).to eq(200)
end
it 'returns the books' do
json = JSON.parse(response.body)
expect(json.length).to eq(5)
end
end
context 'post create' do
before(:each) do
@book_params = Fabricate.attributes_for(:book)
post :create, params: { book: @book_params }
end
it 'has status code 201' do
expect(response.code.to_i).to eq(201)
end
it 'returns book' do
json = JSON.parse(response.body)
expect(Book.all.count).to eq(1)
expect(json['title']).to eq(@book_params['title'])
expect(json['author']).to eq(@book_params['author'])
end
end
end