We created our application and everything is set up for us to write more code and more tests. In this chapter, we will be focusing on completing the basic set of models we need before creating any endpoint. To accompany the Publisher
model, we are going to create the Book
and Author
models. Finally, we will update the Publisher
model since it’s currently incomplete.
The authors
table will have the following columns:
id
given_name
family_name
created_at
updated_at
There are some cultures around the world where people use their family name as the first part of their full name. To avoid having problems with that, it’s usually safer to use the given name/family name combo instead of first name/last name.
The corresponding model
generator looks like this. Run it to create all the files we need to implement authors.
rails g model Author given_name:string family_name:string
Once again, we can see all the files generated in the output. Nothing really new - the files are the same as the ones created for publishers in the previous chapter.
Running via Spring preloader in process 95357
invoke active_record
create db/migrate/TIMESTAMP_create_authors.rb
create app/models/author.rb
invoke rspec
create spec/models/author_spec.rb
invoke factory_bot
create spec/factories/authors.rb
You can take a look at the generated migration for authors
if you want, but we are not going to change anything there so let’s run the migrations.
rails db:migrate && rails db:migrate RAILS_ENV=test
Before we write some tests for authors
in a TDD style, create the two following factories in the spec/factories/authors.rb
file that was generated previously. The best practice for factories is to use real world data and to avoid meaningless data. In this case, we are using three famous authors of Ruby-related books.
# spec/factories/authors.rb
FactoryBot.define do
factory :author do
given_name { 'Pat' }
family_name { 'Shaughnessy' }
end
factory :michael_hartl, class: Author do
given_name { 'Michael' }
family_name { 'Hartl' }
end
factory :sam_ruby, class: Author do
given_name { 'Sam' }
family_name { 'Ruby' }
end
end
You already know how to use Shoulda-Matchers
syntax, so let’s add two tests in the spec file for the Author
model. For now, we are just going to validate that each author has a given_name
and a family_name
.
# spec/models/author_spec.rb
require 'rails_helper'
RSpec.describe Author, :type => :model do
it { should validate_presence_of(:given_name) }
it { should validate_presence_of(:family_name) }
it 'has a valid factory' do
expect(build(:author)).to be_valid
end
end
Run the tests to see them miserably fail.
rspec spec/models/author_spec.rb
Failure (RED)
...
Finished in 0.11861 seconds (files took 1.72 seconds to load)
5 examples, 2 failures
Failed examples:
rspec ./spec/models/author_spec.rb:4 # Author should validate that :given_name
cannot be empty/falsy
rspec ./spec/models/author_spec.rb:5 # Author should validate that :family_name
cannot be empty/falsy
We can easily make those tests pass by adding the right validations rules on the Author
model.
# app/models/author.rb
class Author < ApplicationRecord
validates :given_name, presence: true
validates :family_name, presence: true
end
Try running rspec
again.
rspec spec/models/author_spec.rb
Success (GREEN)
...
Finished in 0.107 seconds (files took 2.27 seconds to load)
5 examples, 0 failures
Great, we’ve got a functional Author
model and the tests for the Publisher
model are still working fine!
Now we’re getting to the juicy part. The Book
model will be at the center of Alexandria. To start, we’ll need a lot more columns than we did for authors
and publishers
.
Here is the list:
id
title
subtitle
isbn_10
:
isbn_13
description
released_on
publisher_id
author_id
created_at
updated_at
The generator command to create the Book
model can be seen below. Copy/paste it in your terminal to quickly generate everything we need.
rails g model Book title:string subtitle:text isbn_10:string \
isbn_13:string description:text released_on:date \
publisher:references author:references
Output
Running via Spring preloader in process 95651
invoke active_record
create db/migrate/TIMESTAMP_create_books.rb
create app/models/book.rb
invoke rspec
create spec/models/book_spec.rb
invoke factory_bot
create spec/factories/books.rb
Now, open the generated migration file, since we need to make a few changes there. First, we are going to add indexes to all the keys that will be heavily used to retrieve books: title
, isbn_XX
, publisher_id
and author_id
. We also want the two isbn
fields to be unique.
You can see all the changes in the migration file below.
# db/migrate/TIMESTAMP_create_books.rb
class CreateBooks < ActiveRecord::Migration[5.2]
def change
create_table :books do |t|
t.string :title, index: true
t.text :subtitle
t.string :isbn_10, index: true, unique: true
t.string :isbn_13, index: true, unique: true
t.text :description
t.date :released_on
t.references :publisher, foreign_key: true, index: true
t.references :author, foreign_key: true, index: true
t.timestamps
end
end
end
Run the migrations for the development
and test
environments.
rails db:migrate && rails db:migrate RAILS_ENV=test
And while we’re at it, let’s add a few factories for the Book
model. As you can see below, we are creating three factories: Ruby Under a Microscope, Ruby on Rails Tutorial and Agile Web Development with Rails 4. We are also linking those books to authors and publishers. Using only publisher
or author
will default to the factory of the same name. We can also specify the association using something like association :author, factory: :michael_hartl
. In that case, the book will be linked to the specified factory.
# spec/factories/books.rb
FactoryBot.define do
factory :book, aliases: [:ruby_microscope] do
title { 'Ruby Under a Microscope' }
subtitle { 'An Illustrated Guide to Ruby Internals' }
isbn_10 { '1593275617' }
isbn_13 { '9781593275617' }
description { 'Ruby Under a Microscope is a cool book!' }
released_on { '2013-09-01' }
publisher
author
end
factory :ruby_on_rails_tutorial, class: Book do
title { 'Ruby on Rails Tutorial' }
subtitle { 'Learn Web Development with Rails' }
isbn_10 { '0134077709' }
isbn_13 { '9780134077703' }
description { 'The Rails Tutorial is great!' }
released_on { '2013-05-09' }
publisher_id { nil }
association :author, factory: :michael_hartl
end
factory :agile_web_development, class: Book do
title { 'Agile Web Development with Rails 4' }
subtitle { '' }
isbn_10 { '1937785564' }
isbn_13 { '9781937785567' }
description { 'Stay agile!' }
released_on { '2015-10-11' }
publisher
association :author, factory: :sam_ruby
end
end
With our factories ready, we can write a bunch of validation tests for the Book
model. We could probably write one test at a time and ensure each test is passing before proceeding.
There are a few more Shoulda-Matchers
matchers here but their name are pretty self-explanatory so I won’t dive into them.
Here are the validation tests for the Book
model:
# spec/models/book_spec.rb
require 'rails_helper'
RSpec.describe Book, :type => :model do
it { should validate_presence_of(:title) }
it { should validate_presence_of(:released_on) }
it { should validate_presence_of(:author) }
it { should validate_presence_of(:isbn_10) }
it { should validate_presence_of(:isbn_13) }
it { should validate_length_of(:isbn_10).is_equal_to(10) }
it { should validate_length_of(:isbn_13).is_equal_to(13) }
it { should validate_uniqueness_of(:isbn_10) }
it { should validate_uniqueness_of(:isbn_13) }
it 'has a valid factory' do
expect(build(:book)).to be_valid
end
end
Run the rspec
command now and you will end up with the following output.
Failure (RED)
...
Finished in 0.31664 seconds (files took 2.14 seconds to load)
15 examples, 9 failures
Failed examples:
...
Our tests are failing as expected. To fix them, we need to add the validation rules inside app/models/book.rb
.
# app/models/book.rb
class Book < ApplicationRecord
belongs_to :publisher
belongs_to :author
validates :title, presence: true
validates :released_on, presence: true
validates :author, presence: true
validates :isbn_10, presence: true, length: { is: 10 }, uniqueness: true
validates :isbn_13, presence: true, length: { is: 13 }, uniqueness: true
end
Let’s run rspec
again.
rspec
Success! (GREEN)
Finished in 0.39823 seconds (files took 1.52 seconds to load)
15 examples, 0 failures
We now have three different models: Publisher
, Author
and Book
. They are all related, but we haven’t associated them in Alexandria yet.
First, an author
should have_many
books
. Let’s add a test using the have_many
method from Shoulda-Matchers
to be sure that the association exists.
# spec/models/author_spec.rb
require 'rails_helper'
RSpec.describe Author, :type => :model do
it { should validate_presence_of(:given_name) }
it { should validate_presence_of(:family_name) }
# An author should indeed have many books!
it { should have_many(:books) }
it 'has a valid factory' do
expect(build(:author)).to be_valid
end
end
A quick rspec
allows us to see that this test is failing, as expected.
rspec spec/models/author_spec.rb
Failure (RED)
...
Finished in 0.08081 seconds (files took 2.44 seconds to load)
4 examples, 1 failure
...
Let’s fix the model by adding the books
association.
# app/models/author.rb
class Author < ApplicationRecord
has_many :books
validates :given_name, presence: true
validates :family_name, presence: true
end
Another rspec
run will now show that our test is correctly passing.
rspec spec/models/author_spec.rb
Success (GREEN)
...
Finished in 0.07633 seconds (files took 1.94 seconds to load)
4 examples, 0 failures
Just like authors
, publishers
can have many books
. It’s going to look a bit repetitive because the tests and the code will be exactly like the ones we just wrote for authors
. Let’s go through it quickly anyhow!
Add a new test to the publisher_spec
file to ensure that publishers
have many books
.
# spec/models/publisher_spec.rb
require 'rails_helper'
RSpec.describe Publisher, :type => :model do
it { should validate_presence_of(:name) }
it { should have_many(:books) }
it 'has a valid factory' do
expect(build(:publisher)).to be_valid
end
end
And as expected, a quick rspec
will highlight the failure of this new test.
rspec spec/models/publisher_spec.rb
Failure (RED)
...
Finished in 0.13353 seconds (files took 2.54 seconds to load)
3 examples, 1 failure
...
As you already know, we can easily fix it by adding has_many :books
in the Publisher
model.
# app/models/publisher.rb
class Publisher < ApplicationRecord
has_many :books
validates :name, presence: true
end
Another rspec
run, and everything is now working fine.
rspec spec/models/publisher_spec.rb
Success (GREEN)
...
Finished in 0.08516 seconds (files took 2.34 seconds to load)
3 examples, 0 failures
Now, we need to define that books
belong to authors
and publishers
.
The associations were already generated when we created the Book
model, so we only need to add tests to check that they are working as expected.
# spec/models/book_spec.rb
require 'rails_helper'
RSpec.describe Book, :type => :model do
it { should validate_presence_of(:title) }
it { should validate_presence_of(:released_on) }
it { should validate_presence_of(:author) }
it { should validate_presence_of(:isbn_10) }
it { should validate_presence_of(:isbn_13) }
it { should belong_to(:publisher) }
it { should belong_to(:author) }
it { should validate_length_of(:isbn_10).is_equal_to(10) }
it { should validate_length_of(:isbn_13).is_equal_to(13) }
it { should validate_uniqueness_of(:isbn_10) }
it { should validate_uniqueness_of(:isbn_13) }
it 'has a valid factory' do
expect(build(:book)).to be_valid
end
end
Let’s run rspec
to see the new tests succeed.
rspec spec/models/book_spec.rb
Success (GREEN)
...
Finished in 0.33886 seconds (files took 2.23 seconds to load)
12 examples, 0 failures
Note that with Rails 5, you now need to use required: false
for an optional association. Publishers are optional since authors can self-publish their books. In that case, the publisher
should be nil
.
# app/models/book.rb
class Book < ApplicationRecord
belongs_to :publisher, required: false
belongs_to :author
validates :title, presence: true
validates :released_on, presence: true
validates :author, presence: true
validates :isbn_10, presence: true, length: { is: 10 }, uniqueness: true
validates :isbn_13, presence: true, length: { is: 13 }, uniqueness: true
end
We can run the tests one last time to ensure that we didn’t break anything.
rspec spec/models/book_spec.rb
Success (GREEN)
...
Finished in 0.35174 seconds (files took 1.82 seconds to load)
12 examples, 0 failures
Before we can start working on the books
controller that will basically turn our application from a list of models to a real API, we have one last thing to do.
You shouldn’t judge a book by its cover, but it should still have one anyway. Uploading files to a web API is a bit different from using an HTML form and the multipart/form-data
media type.
Since any client should be able to send images to our API, we are going to use a different approach that will work in the same way with mobile and JavaScript applications. The idea is simply to encode the file in the client in Base64, send it, and let the server decode it and store it.
For this purpose, we’re going to use carrierwave and carrierwave-base64 for that purpose.
The first thing we need to do is add the gems to our Gemfile
.
# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.5.0'
gem 'rails', '5.2.0'
gem 'sqlite3'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
# We add those two gems
gem 'carrierwave'
gem 'carrierwave-base64'
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'rspec-rails'
gem 'factory_bot_rails'
end
group :development do
gem 'listen', '>= 3.0.5', '< 3.2'
gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0'
end
group :test do
gem 'shoulda-matchers'
gem 'webmock'
gem 'database_cleaner'
end
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
Run bundle install
to install them.
We can then use the generator coming with CarrierWave
to create an uploader. Uploaders are PORO (Plain Old Ruby Objects) that will handle the upload process for files.
Run this command to generate an uploader named Cover
that we will use in the Book
model.
rails generate uploader Cover
Output
Running via Spring preloader in process 74108
create app/uploaders/cover_uploader.rb
Once cleaned up, your uploader should look like the code below. For now, this configuration is good enough. With it, we specify that any uploaded file will be stored as a file in the project (instead of uploading it to AWS S3 for example) and that we only allow three extensions: jpg
, jpeg
and png
.
# app/uploaders/cover_uploader.rb
class CoverUploader < CarrierWave::Uploader::Base
storage :file
def default_url
url = "uploads/#{model.class.to_s.underscore}/#{mounted_as}/default/cover.jpg"
url.prepend('/') unless url[0] == '/'
url
end
def store_dir
"uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
end
def extension_white_list
%w(jpg jpeg png)
end
end
Let’s also add a default cover. Create the following folders:
mkdir -p public/uploads/book/cover/default
And copy the cover.jpg
file from the resources/
folder you received with Master Ruby Web APIs.
Next, we need to add a column to the books
table. Indeed, CarrierWave
needs that new column to store the file url.
rails g migration add_cover_to_books cover:string
Output
Running via Spring preloader in process 74377
invoke active_record
create db/migrate/TIMESTAMP_add_cover_to_books.rb
The generated migration looks like this. We are not going to change anything just take a quick look and let’s proceed.
# db/migrate/TIMESTAMP_add_cover_to_books.rb
class AddCoverToBooks < ActiveRecord::Migration[5.2]
def change
add_column :books, :cover, :string
end
end
Run the migration for the development
and test
environments.
rails db:migrate && rails db:migrate RAILS_ENV=test
Finally, we need to add the following line to the Book
model.
mount_base64_uploader :cover, CoverUploader
Here is the complete Book
model for reference.
# app/models/book.rb
class Book < ApplicationRecord
belongs_to :publisher, required: false
belongs_to :author
validates :title, presence: true
validates :released_on, presence: true
validates :author, presence: true
validates :isbn_10, presence: true, length: { is: 10 }, uniqueness: true
validates :isbn_13, presence: true, length: { is: 13 }, uniqueness: true
mount_base64_uploader :cover, CoverUploader
end
If you try to run the rails c
command now, you might end up with an uninitialized constant error. If you do, add the two lines below to the config/application.rb
file. If you don’t have the problem, you can just skip this section.
require 'carrierwave'
require 'carrierwave/orm/activerecord'
Here is the updated application.rb
file.
# config/application.rb
require_relative 'boot'
require "rails"
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "action_cable/engine"
require "rails/test_unit/railtie"
require 'carrierwave'
require 'carrierwave/orm/activerecord'
Bundler.require(*Rails.groups)
module Alexandria
class Application < Rails::Application
config.load_defaults 5.2
config.api_only = true
end
end
rails c
should now be working properly.
It’s now time to push our changes to GitHub. The flow is the same as in the previous chapter. But first, let’s run our test suite to ensure that everything is still working.
rspec
Success (GREEN)
Author
should validate that :given_name cannot be empty/falsy
should validate that :family_name cannot be empty/falsy
should have many books
has a valid factory
Book
should validate that :title cannot be empty/falsy
should validate that :released_on cannot be empty/falsy
should validate that :author cannot be empty/falsy
should validate that :isbn_10 cannot be empty/falsy
should validate that :isbn_13 cannot be empty/falsy
should belong to publisher
should belong to author
should validate that the length of :isbn_10 is 10
should validate that the length of :isbn_13 is 13
should validate that :isbn_10 is case-sensitively unique
should validate that :isbn_13 is case-sensitively unique
has a valid factory
Publisher
should validate that :name cannot be empty/falsy
should have many books
has a valid factory
Finished in 0.67854 seconds (files took 2.63 seconds to load)
19 examples, 0 failures
Great! Here is the list of steps to push the code.
Check the changes.
git status
Output
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: Gemfile
modified: Gemfile.lock
modified: app/models/publisher.rb
modified: config/application.rb
modified: db/schema.rb
modified: spec/models/publisher_spec.rb
Untracked files:
(use "git add <file>..." to include in what will be committed)
app/models/author.rb
app/models/book.rb
app/uploaders/
db/migrate/20180822101932_create_authors.rb
db/migrate/20180822102247_create_books.rb
db/migrate/20180822103014_add_cover_to_books.rb
spec/factories/authors.rb
spec/factories/books.rb
spec/models/author_spec.rb
spec/models/book_spec.rb
no changes added to commit (use "git add" and/or "git commit -a")
Stage them.
git add .
Commit the changes.
git commit -m "Add Author & Book models"
Output
[master a7fde02] Add Author & Book models
15 files changed, 201 insertions(+), 1 deletion(-)
create mode 100644 app/models/author.rb
create mode 100644 app/models/book.rb
create mode 100644 app/uploaders/cover_uploader.rb
create mode 100644 db/migrate/20160601062143_create_authors.rb
create mode 100644 db/migrate/20160601062528_create_books.rb
create mode 100644 db/migrate/20160601083917_add_cover_to_books.rb
create mode 100644 spec/factories/authors.rb
create mode 100644 spec/factories/books.rb
create mode 100644 spec/models/author_spec.rb
create mode 100644 spec/models/book_spec.rb
Push to GitHub.
git push origin master
Output
Counting objects: 25, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (24/24), done.
Writing objects: 100% (25/25), 4.16 KiB | 0 bytes/s, done.
Total 25 (delta 6), reused 0 (delta 0)
To https://github.com/T-Dnzt/alexandria.git
24b96fe..a7fde02 master -> master
In this chapter, we have created two more models: Author
and Book
. We also created relations between our three models to follow the same kind of logic we would have in the real world. Now that our models are ready, we can start building the books
controller.