Application example¶
The business we will be discussing is simple. We plan to establish a book library where registered users can create new books and rent them.
Book library requirements¶
We need a basic book tracking system in which every book is identified by a title. The following rules will apply:
- The book list will be publicly accessible to everyone.
- Only users who are logged in will be allowed to modify the book list.
- Logged-in users will have the ability to edit or delete a book.
The rental system¶
Logged-in users will have the ability to manage books, taking into account the following:
- Users should be able to rent any available book.
- Only logged-in users will be able to rent books.
- A book will be marked as unavailable while it is rented out to someone.
- After a book is returned, it will be marked as available once again.
The books wishlist¶
Logged-in users will have the ability to manage their wishlist, taking into account the following:
- Users should be able to add books to their wishlist if the book is currently unavailable and they have not yet read it.
- If a user has already read a book, adding it to their wishlist would not be practical.
- Once a book on a user's wishlist becomes available, the system should notify them.
- When the book is rented by someone that has the book on the wishlist, it should be removed after return.
The application domain is straightforward, and we will be building this small logic case step by step to demonstrate how Granite can simplify and streamline certain aspects of your application.
New project setup¶
We are working here with Rails 5.1, however, Granite currently supports Rails versions up to 7.x.x
. You can find an example of this application following the link: https://github.com/toptal/example_granite_application
Generating a new project¶
This tutorial is using Rails version 5.1.4
, and the first step is to install it:
gem install rails -v=5.1.4
Now, with the proper Rails version, let's start a new project:
rails new library
cd library
Let's start setting up the database for development:
rails db:setup
Setup Devise¶
Let's add devise to control users access and have a simple control over logged
users. Adding it to Gemfile
.
gem 'devise'
Run bundle install
and then generate the default devise resources.
rails generate devise:install
And then, let's create a simple devise model to interact with:
rails generate devise user
And migrate again:
rails db:migrate
Info
If you get in any trouble in this section, please check the updated documentation on the official website.
Setup Granite¶
Add granite
to your Gemfile
:
gem 'granite'
And bundle install
again.
Add require 'granite/rspec'
to your rails_helper.rb
. Check more details on
the testing section.
Warning
If you get in any trouble in this section, please report an issue.
Books domain¶
Book::Create action¶
It's time to create our first model and has some domain on it.
Let's use a scaffold to have a starting point with the Book
model:
rails g scaffold book title:string
Now, we can start working on the first business action.
Let's generate the boilerplate business action class with Rails granite
generator:
rails g granite book/create
The following class was generated:
# apq/actions/ba/book/create.rb
class BA::Book::Create < BA::Book::BusinessAction
allow_if { false }
precondition do
end
private
def execute_perform!(*)
subject.save!
end
end
Additionally, a "base", shared class is generated, that defines the subject type for all the inherited classes in the namespace Book.
class BA::Book::BusinessAction < BaseAction
subject :book
end
Policies¶
The generated code says allow_if { false }
and we need to restrict it to
logged in users. Let's replace this line to restrict the action only for logged in users:
# apq/actions/ba/book/create.rb
class BA::Book::Create < BA::Book::BusinessAction
allow_if { performer.is_a?(User) }
# ...
end
And let's start testing it:
require 'rails_helper'
RSpec.describe BA::Book::Create do
subject(:action) { described_class.as(performer).new }
let(:performer) { User.new }
describe 'policies' do
it { is_expected.to be_allowed }
context 'when the user is not authorized' do
let(:performer) { double }
it { is_expected.not_to be_allowed }
end
end
end
Attributes¶
We also need to be specific about what attributes this action can touch and then we need to define attributes for it:
# apq/actions/ba/book/create.rb
class BA::Book::Create < BA::Book::BusinessAction
# ...
represents :title, of: :subject
# ...
end
We can define some validations to not allow saving without specifying a title:
# apq/actions/ba/book/create.rb
class BA::Book::Create < BA::Book::BusinessAction
# ...
validates :title, presence: true
# ...
end
And now we can properly test it:
require 'rails_helper'
RSpec.describe BA::Book::Create do
subject(:action) { described_class.as(performer).new(attributes) }
let(:performer) { User.new }
let(:attributes) { { 'title' => 'Ruby Pickaxe'} }
describe 'policies' do
it { is_expected.to be_allowed }
context 'when the user is not authorized' do
let(:performer) { double }
it { is_expected.not_to be_allowed }
end
end
describe 'validations' do
it { is_expected.to be_valid }
context 'when preconditions fail' do
let(:attributes) { { } }
it { is_expected.not_to be_valid }
end
end
end
Perform¶
For now, the perform is a simple call to book.save!
because Granite already
assign the attributes.
Then we need to test if it's generating the right record:
require 'rails_helper'
RSpec.describe BA::Book::Create do
subject(:action) { described_class.as(performer).new(attributes) }
let(:performer) { User.new }
let(:attributes) { { 'title' => 'Ruby Pickaxe'} }
describe 'policies' do
it { is_expected.to be_allowed }
context 'when the user is not authorized' do
let(:performer) { double }
it { is_expected.not_to be_allowed }
end
end
describe 'validations' do
it { is_expected.to be_valid }
context 'when preconditions fail' do
let(:attributes) { { } }
it { is_expected.not_to be_valid }
end
end
+ describe '#perform!' do
+ specify do
+ expect { action.perform! }.to change { Book.count }.by(1)
+ expect(action.subject.attributes.except('id', 'created_at', 'updated_at')).to eq(attributes)
+ end
+ end
end
The last step is to replace the current book creation in the controller to call the business action instead.
First thing is rescue from Granite::NotAllowed
when some action is not allowed
to be executed.
class BooksController < ApplicationController
rescue_from Granite::Action::NotAllowedError do |exception|
redirect_to books_path, alert: "You're not allowed to execute this action."
end
# ...
end
It will generically manage exceptions in case some unauthorized user tries to force acting without having access.
The next step is to wrap the method #create
with the proper business action call.
class BooksController < ApplicationController
# ...
# POST /books
def create
book_action = BA::Book::Create.as(current_user).new(book_params)
if book_action.perform
redirect_to book_action.subject, notice: 'Book was successfully created.'
else
@book = book_action.subject
render :new
end
end
end
# ...
end
Book::Rent action¶
To start renting the book, we need a few steps:
- Generate migration to create the rental table referencing the book and the user
- Add an
available
boolean column in the books table - Create a business action
Book::Rent
and test the conditions above
Let's create Rental
model first:
rails g model rental book:references user:references returned_at:timestamp
and add an available
column in the books table:
rails g migration add_availability_to_books available:boolean
Now it's time to generate the next Granite action:
rails g granite book/rent
Preconditions¶
Let's write specs for the preconditions first:
RSpec.describe BA::Book::Rent do
subject(:action) { described_class.as(performer).new(book) }
let(:performer) { User.new }
let(:book) { Book.new(title: 'First book', available: available) }
describe 'preconditions' do
context 'with an available book' do
let(:available) { true }
it { is_expected.to satisfy_preconditions }
end
context 'with an unavailable book' do
let(:available) { false }
it { is_expected.to be_invalid }
it { is_expected.not_to satisfy_preconditions }
end
end
end
Preconditions are related to the book in the context. And the action will decline the context not to be executed if it does not satisfy the preconditions.
Let's implement the precondition
and perform!
code:
class BA::Book::Rent < BA::Book::BusinessAction
+ precondition { book.available? }
private
def execute_perform!(*)
Rental.create!(book: subject, user: performer)
subject.available = false
subject.save!
end
end
Now, let's cover the perform with another spec:
RSpec.describe BA::Book::Rent do
subject(:action) { described_class.as(performer).new(book) }
let(:performer) { User.new }
let(:book) { Book.new(title: 'First book', available: available) }
+ describe 'preconditions' do
+ context 'with an available book' do
+ let(:available) { true }
+ it { is_expected.to satisfy_preconditions }
+ end
+
+ context 'with an unavailable book' do
+ let(:available) { false }
+ it { is_expected.to be_invalid }
+ it { is_expected.not_to satisfy_preconditions }
+ end
+ end
describe '#perform!' do
specify do
expect { action.perform! }
.to change(book, :available).from(true).to(false)
.and change(Rental, :count).by(1)
end
end
end
Book::Return action¶
First, think about the policies: to return the book, it needs to be rented by the person that is logged in.
Then we need to have a precondition to verify if the current book is being rented by this person:
class BA::Book::Return < BA::Book::BusinessAction
precondition do
rental_conditions = { book: subject, user: performer, returned_at: nil }
Rental.where(rental_conditions).exists?
end
end
The logic of the return, we just need to pick the current rental and
assign the returned_at
date. Also, make the book available again.
Let's start by testing the preconditions and guarantee that only the user that rent the book can return it.
RSpec.describe BA::Book::Return do
subject(:action) { described_class.as(performer).new(book) }
let(:book) { Book.create! title: 'Learn to fly', available: true }
let(:performer) { User.create! }
describe 'preconditions' do
context 'when the user rented the book' do
before { BA::Book::Rent.as(performer).new(book).perform! }
it { is_expected.to satisfy_preconditions }
end
context 'when preconditions fail' do
it { is_expected.not_to satisfy_preconditions }
end
end
end
And implementing the preconditions:
class BA::Book::Return < BA::Book::BusinessAction
subject :book
allow_if { performer.is_a?(User) }
precondition do
decline_with(:not_renting) unless performer.renting?(book)
end
end
And the User
now have a few scopes and the #renting?
method:
class User < ApplicationRecord
devise :database_authenticatable, :registerable
has_many :rentals
has_many :books, through: :rentals
def renting?(book)
rentals.current.where(book: book).exists?
end
end
Now implementing the spec that covers the logic of return, is expected to make the book available and mark the rental with the given date.
RSpec.describe BA::Book::Return do
subject(:action) { described_class.as(performer).new(book) }
let(:book) { Book.create! title: 'Learn to fly', available: true }
let(:performer) { User.create! }
describe 'preconditions' do
context 'when the user rented the book' do
before { BA::Book::Rent.as(performer).new(book).perform! }
it { is_expected.to satisfy_preconditions }
end
context 'when preconditions fail' do
it { is_expected.not_to satisfy_preconditions }
end
end
+ describe '#perform!' do
+ let!(:rental) { Rental.create! book: book, user: performer }
+
+ specify do
+ expect { action.perform! }
+ .to change { book.reload.available }.from(false).to(true)
+ .and change { rental.reload.returned_at }.from(nil)
+ end
+ end
end
I18n¶
The last step to make it user-friendly and return a personalized
message when the business action calls decline_with(:unavailable)
.
It's time to create the internationalization file for it.
File: config/locales/granite.en.yml
en:
granite_action:
errors:
models:
ba/book/rent:
attributes:
base:
unavailable: 'The book is unavailable.'
Application layer¶
Great! Now it's time to change our views to allow people to interact with the actions we created.
First, we need to add controller methods to call the Rent
and Return
business actions and create routes for it.
class BooksController < ApplicationController
# a few other scaffold methods here
# POST /books/1/rent
def rent
@book = Book.find(params[:book_id])
book_action = BA::Book::Rent.as(current_user).new(@book)
if book_action.perform
redirect_to books_url, notice: 'Enjoy the book!'
else
redirect_to books_url, alert: book_action.errors.full_messages
end
end
# POST /books/1/return_book
def return_book
@book = Book.find(params[:book_id])
book_action = BA::Book::Return.as(current_user).new(@book)
if book_action.perform
redirect_to books_url, notice: 'Thanks for delivering it back.'
else
redirect_to books_url, alert: book_action.errors.full_messages
end
end
end
end
And add routes for rent
and return_book
in config/routes.rb
:
resources :books do
post :rent
post :return_book
end
Now, it's time to change the current view to add such actions:
<tbody>
<% @books.each do |book| %>
<tr>
<td><%= book.title %></td>
<% if book.available? %>
<td><%= link_to 'Rent', rent_book_path(book), method: :post %></td>
<% else %>
<td>(Rented)</td>
<% end %>
<% if current_user && current_user.renting?(book) %>
<td><%= link_to 'Return', return_book_path(book), method: :post %></td>
<% end %>
<td><%= link_to 'Show', book %></td>
<td><%= link_to 'Edit', edit_book_path(book) %></td>
<td><%= link_to 'Destroy', book, method: :delete, data: { confirm: 'Do you really want to destroy this book?' } %></td>
</tr>
<% end %>
</tbody>
Now is a good opportunity to introduce projectors.
The actual implementation contains a few boilerplate code in the controller that make us repeat a few different logics that are already in the business action.
Projectors can help with that. Avoiding the need for creating repetitive controller methods and re-verify preconditions and policies to decide what actions can be executed.
Setup view context for Granite projector¶
You'll need to set up the master controller class. Let's create a file to configure what will be the base controller for Granite:
File: config/initializers/granite.rb
Granite.tap do |m|
m.base_controller = 'ApplicationController'
end
The next step is to change ApplicationController
to setup context
view and allow Granite to inherit behavior from it.
app/controllers/application_controller
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
around_action :setup_granite_view_context
before_action { view_context }
protected
def setup_granite_view_context(&block)
Granite.with_view_context(view_context, &block)
end
end
Inline projector¶
The current rent
and returned_at
methods have a very similar structure.
And the projectors allows to declare the HTTP method like get
or post
and mount it in a route as an anonymous controller.
Inside the block, the action is already set with all parameters from the web request and ready to be executed.
As the current controller actions are executed with POST
, let's follow the same line and
create a simple projector that allows to receive some post data and redirect to the resources list back.
The projector will have a default success_redirect
and failure_redirect
after the action execution.
By default, let's assume that we'll redirect it to the collection and render a
positive notice or a negative alert to the previous action.
class InlineProjector < Granite::Projector
post :perform, as: '' do
if action.perform!
redirect_to projector.success_redirect, notice: t('.notice')
else
messages = projector.action.errors.full_messages.to_sentence
redirect_to projector.failure_redirect, alert: t('.error', messages)
end
end
def collection_subject
action.subject.class.name.downcase.pluralize
end
def success_redirect
h.public_send("#{collection_subject}_path")
end
def build_action(*args)
action_class.with(self.class.proxy_context || {performer: h.current_user}).new(*args)
end
end
We also need to say who is the performer of the action.
The build_action
method in the projector is implemented to override the
current performer in action with the current_user
.
Info
Note that h
is an alias for view_context
and you can access anything
from the controller through it.
Now, it's time to say that we're going to use the projector inside the Rent
action:
File: apq/actions/ba/book/rent.rb
class BA::Book::Rent < BaseAction
subject :book
+ projector :inline
allow_if { performer.is_a?(User) }
precondition do
decline_with(:unavailable) unless book.available?
end
private
def execute_perform!(*)
subject.available = false
subject.save!
Rental.create!(book: subject, user: performer)
end
end
And also drop the method from the BooksController
:
File: app/controllers/books_controller.rb
@@ -25,28 +25,6 @@ class BooksController < ApplicationController
@book = Book.find(params[:id])
end
- # POST /books/1/rent
- def rent
- @book = Book.find(params[:book_id])
- book_action = BA::Book::Rent.as(current_user).new(@book)
- if book_action.perform
- redirect_to books_url, notice: 'Book was successfully rented.'
- else
- redirect_to books_url, alert: book_action.errors.full_messages.to_sentence
- end
- end
As the last step, we need to change the config/routes.rb
to use the granite
to mount the action#projector
into the defined routes.
File: config/routes.rb
Rails.application.routes.draw do
root 'books#index'
devise_for :users
resources :books do
- post :rent
+ granite 'BA/book/rent#inline'
post 'return', to: 'books#return_book', as 'return'
end
end
Warning
As it's a tutorial, your next task is to do the same for return_book
.
- Add
projector :inline
toBA::Book::Return
class. - Remove the controller method
- Refactor the
config/routes.rb
declaring thegranite 'action#projector'
Projector Helpers¶
You can define useful methods for helping you rendering your view and improving
the experience with your actions. Now, let's create a button
function,
to replace the action links in the current list.
First, we need to have a method in our projector that can render the button if the action is performable.
It will render nothing if the current user does not have access or it's an anonymous session.
We'll render the action name stricken if the action is not performable with the error messages in the title, because if people mouse over, they can see the "tooltip" with why it's not possible to execute the action.
class InlineProjector < Granite::Projector
# ...
# The previous methods remain here
# ...
def button(link_options = {})
return unless action.allowed?
if action.performable?
h.link_to action_label, perform_path, method: :post
end
end
def action_label
action.class.name.demodulize.underscore.humanize
end
end
And now, we can replace the links with the new button
function:
<tbody>
<% @books.each do |book| %>
<tr>
<td><%= book.title %></td>
<td><%= Ba::Book::Rent.as(current_user).new(book).inline.button%></td>
<td><%= Ba::Book::Return.as(current_user).new(book).inline.button%></td>
<td>... more links here ...</td>
</tr>
<% end %>
</tbody>
Now it's clear, and the "Return" link will appear only for the user that rented the book.
Wishlist domain¶
As we are still working on the description of the wishlist domain, we encourage you to take the initiative and implement it yourself. We believe this will be a great opportunity for you to apply your own creativity and problem-solving skills, and we are always here to support you along the way.
Wishlist::Add action¶
To be done.
Wishlist::Remove action¶
To be done.
Wishlist::NotifyAvailability action¶
To be done.
Conclusion¶
To conclude, you have learned how to work with Granite, and we are excited to see you apply this knowledge to your upcoming project. We are confident that Granite will simplify your development process and allow you to focus on the more important aspects of your application. As always, please do not hesitate to reach out to us if you have any questions or need further assistance.