Chapter 13

Adding Books and Authors

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.

13.1. The Author Model

The authors table will have the following columns:

  • id
  • given_name
  • family_name
  • created_at
  • updated_at
Box 13.1. Given Name/Family Name vs First Name/ Last Name

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!

13.2. The Book Model

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

13.3. Associations

We now have three different models: Publisher, Author and Book. They are all related, but we haven’t associated them in Alexandria yet.

13.3.1. Author associations

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

13.3.2. Publisher Associations

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.

13.3.3. Books Associations

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

13.4. Handling Book Covers

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.

13.5. Pushing Our Changes

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

13.6. Wrap Up

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.