Write a REST API with a single command
At Fabernovel, when we write a REST API, we want to synchronize our tests with json schemas from our documentation, we want to write interactors for each CRUD operation and we also want to write our resources inside a namespace. Doing these extra steps greatly increases the readability and the maintainability of our code base. But it also increases the boilerplate a developer needs to write.
Inheritance at the rescue?
Inheritance can be used as a refactoring tool and is a way to avoid code duplication. For example, Active Admin uses the gem inherited_resources. It allows us to create a CRUD controller by simply inheriting a base controller.
class ProjectsController < InheritedResources::Base
end
It is very simple, it follows the Convention over Configuration principle and is definitely not boilerplate. However, there is a problem with this solution: how do you customize the behaviour ? What methods should you override ? For example, how do you solve these use cases:
- You need to transform the request before creating your resource like converting an amount with included taxes to a tax-free amount.
- You need to send an email after creating a resource or when an error occurred
- You need to perform a payment when creating a resource
- You need to restrict access and only respond with a subset of the current resource like delivering only the user’s projects.
There are solutions but if you need to look at the documentation from inherited_resources
then you increased
the entry barrier.
It is also easy to misuse this gem. Do you know when to override collection
or end_of_association_chain
?
Do you know when to override create_resource
or use create!
? It’s easy to find the answers in the documentation but
if the code is already in in your project, you will reuse the current method without knowing if there are
better ways to solve your problem.
There is another flaw in this gem: we are writing fat controllers and are reducing testability and reusability. We know that we can solve this problem with interactors. But how can we avoid writing the boilerplate code introduced by it ?
Code generation
Rails is shipped with a generator tool built on top of Thor. It allow us to use a cli
to generate source code. For example, rails g model Project
will create
invoke active_record
create db/migrate/20210322133155_create_projects.rb
create app/models/project.rb
invoke test_unit
create test/models/project_test.rb
We can use this tool to create our own generator that will meet our goals when it comes to architecture. We can rely on the current database schema to write code that we would have written by hand. Generating code solves our problem of having to write boilerplate.
This first step is to create our generator: rails g generator clean_code
create lib/generators/clean_code
create lib/generators/clean_code/clean_code_generator.rb
create lib/generators/clean_code/USAGE
create lib/generators/clean_code/templates
invoke test_unit
create test/lib/generators/clean_code_generator_test.rb
Model
Then, we need to create two templates to create our model and the associated test. Our architecture is based on namespaces to reflect the Screaming Architecture, so we copy our files in a domains folder.
def copy_model
template 'model.rb.erb', "app/domains/#{file_path}.rb"
template 'model_test.rb.erb', "test/domains/#{file_path}_test.rb"
end
The generated model will look like this:
class Project < DomainModel
validates :name, presence: true
validates :is_vacations, inclusion: [true, false]
# ...
belongs_to :company, required: true, inverse_of: :projects
belongs_to :project_type, required: true, inverse_of: :projects
# ...
has_many :expenses, inverse_of: :project, dependent: :restrict_with_error
has_many :bills, inverse_of: :project, dependent: :restrict_with_error
# ...
end
For the model, we use database constraints to generate validations and foreign key to generatebelongs_to
/has_many
relations.
By generating the code and not writing it, we avoid typos on relation names, we don’t forget to set inverse_of
.
In case we had forgotten, we think beforehand of what we should do with has_many
relations if we try to destroy our object.
Finally, the generated test is here to ensure validations are correctly set.
class ProjectTest < ActiveSupport::TestCase
test 'should have name' do
assert_not build(:project, name: nil).valid?
end
test 'should have is_vacations' do
assert_not build(:project, is_vacations: nil).valid?
end
# ...
test 'should have company' do
assert_not build(:project, company: nil).valid?
end
test 'should have project_type' do
assert_not build(:project, project_type: nil).valid?
end
# ...
end
That kind of test seems a bit dumb but there are two reasons we create them. First, we need a placeholder. If we want to add
a test, it is easier to do it when the test class already exists. And secondly, for every feature we want a test. In a perfect
world, if we mutate our code, a test should fail. We don’t test the framework, we are testing that the validations are applied.
Rails validations behaviour can change. If you did the upgrade of Rails to 5.0,
you know the required: true
is now the default option for belongs_to
. Doing this upgrade (and probably future upgrades)
is a lot easier with a good test suite.
Templates code
<% module_namespacing do -%>
class <%= class_name %> < DomainModel<% primitive_non_nullable_columns.each do |column| %>
<%= column_validation(column) -%>
<% end -%>
<% belongs_to_relations.each do |column| %>
<%= belongs_to(column) -%>
<% end -%>
<% has_many_relations.each do |table| %>
<%= has_many(table) -%>
<% end %>
end
<% end -%>
require 'test_helper'
<% module_namespacing do -%>
class <%= class_name %>Test < ActiveSupport::TestCase
<%- non_nullable_columns.each do |column| -%>
test 'should have <%= column.name.gsub(/_id$/, '') %>' do
assert_not build(:<%= resource_var_name %>, <%= column.name.gsub(/_id$/, '') %>: nil).valid?
end
<% end -%>
end
<% end -%>
Controller
We do the same for our controller which contains the majority of the boilerplate.
class ProjectsController < ApplicationController
def index
monad = GetProjects.new.call(initiator: current_user, params: params)
if monad.success?
render_json_collection(monad.value!, each_serializer: ShortProjectSerializer)
else
handle_failure(**monad.failure)
end
end
# ...
def create
monad = CreateProject.new.call(initiator: current_user, params: params)
if monad.success?
render_json_success(monad.value!, serializer: DetailedProjectSerializer, status: :created)
else
handle_failure(**monad.failure)
end
end
# ...
private
def handle_failure(code:, details:)
case code
when :invalid_project
render_json_record_errors(details)
when :project_not_found
not_found
else
render_default_error_codes(code: code, details: details)
end
end
end
Other actions like show, update, destroy are also generated but for the sake of simplicity they are omitted. Our controllers’
mission is just to handle the http requests and responses. All the business logic is done in our interactors
(GetProjects
and CreateProject
, here). The contract of our interactors is that they all have a method call
with
keywords arguments and return a monad (See dry-monads ). If the interactor action is
a success we simply render our project and if it is an error then we render the error using its error code. We assume the controller uses devise
and provides a current_user
as more than 80% of our controllers require authentication.
class ProjectsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = create(:user)
sign_in @user
@project = create(:project)
end
test 'should get projects' do
get project_projects_path
assert_response :success
validate_json_response_body '/project/get_project_projects_shortproject_200'
assert_not_empty response.parsed_body['projects']
end
test 'should create project' do
assert_difference -> { Project.count } do
post_project
end
assert_response :created
validate_json_request_body '/project/request_post_project_projects_createproject'
validate_json_response_body '/project/post_project_projects_detailedproject_201'
end
test 'anonymous should not get projects' do
logout @user
get project_projects_path
assert_response :unauthorized
end
test 'anonymous should not create project' do
logout @user
assert_no_difference -> { Project.count } do
post_project
end
assert_response :unauthorized
end
def post_project
post project_projects_path, params: {
project: attributes_for(:project)
}
end
end
The generated test looks like every rails controller test except for the validate_json
part.
Using our internal documentation tool Pericles, we can download and versionize the json schemas describing all http requests and responses for our project in our repository.
Hence, in our tests when we perform the request or receive a response, we can verify that they match the specification that we got from our documentation. Using a task, we automatically download the
schemas and save them to our VCS. Using the schemas in our tests allows us to keep the documentation up to date.
When we run our generator, the REST endpoints that we added to our project are also automatically added to the documentation.
Templates code
<% module_namespacing do -%>
class <%= class_name.pluralize %>Controller < ApplicationController
<%- if class_is_defined?("Get#{class_name.pluralize}") -%>
def index
monad = Get<%= class_name.pluralize %>.new.call(initiator: current_user, params: params)
if monad.success?
render_json_collection(monad.value!, each_serializer: Short<%= class_name %>Serializer)
else
handle_failure(**monad.failure)
end
end
<%- end -%><%- if class_is_defined?("Get#{class_name}") -%>
def show
monad = Get<%= class_name %>.new.call(initiator: current_user, id: params[:id])
if monad.success?
render_json_success(monad.value!, serializer: Detailed<%= class_name %>Serializer)
else
handle_failure(**monad.failure)
end
end
<%- end -%><%- if class_is_defined?("Update#{class_name}") -%>
def update
monad = Update<%= class_name %>.new.call(initiator: current_user, params: params)
if monad.success?
render_json_success(monad.value!, serializer: Detailed<%= class_name %>Serializer)
else
handle_failure(**monad.failure)
end
end
<%- end -%><%- if class_is_defined?("Create#{class_name}") -%>
def create
monad = Create<%= class_name %>.new.call(initiator: current_user, params: params)
if monad.success?
render_json_success(monad.value!, serializer: Detailed<%= class_name %>Serializer, success_status: :created)
else
handle_failure(**monad.failure)
end
end
<%- end -%><%- if class_is_defined?("Destroy#{class_name}") -%>
def destroy
monad = Destroy<%= class_name %>.new.call(initiator: current_user, id: params[:id])
if monad.success?
render_json_success(monad.value!, serializer: Detailed<%= class_name %>Serializer, success_status: :no_content)
else
handle_failure(**monad.failure)
end
end
<%- end -%>
private
def handle_failure(code:, details:)
case code
when :invalid_<%= resource_var_name %>
render_json_record_errors(details)
when :<%= resource_var_name %>_not_found
not_found
else
render_default_error_codes(code: code, details: details)
end
end
end
<% end -%>
require 'test_helper'
<% module_namespacing do -%>
class <%= class_name.pluralize %>ControllerTest < ActionDispatch::IntegrationTest
setup do
@user = create(:user)
sign_in @user
@<%= resource_var_name %> = create(:<%= resource_var_name %>)
end
<%- if class_is_defined?("Get#{class_name.pluralize}") -%>
test 'should get <%= resource_var_name.pluralize %>' do
get <%= domain_name.underscore %>_<%= resource_var_name.pluralize %>_path
assert_response :success
validate_json_response_body '/<%= resource_var_name %>/get_<%= domain_name.underscore %>_<%= resource_var_name.pluralize %>_short<%= resource_var_name %>_200'
assert_not_empty response.parsed_body['<%= resource_var_name.pluralize %>']
end
<%- end -%><%- if class_is_defined?("Get#{class_name}") -%>
test 'should get <%= resource_var_name %>' do
get <%= domain_name.underscore %>_<%= resource_var_name %>_path(@<%= resource_var_name %>)
assert_response :success
validate_json_response_body '/<%= resource_var_name %>/get_<%= domain_name.underscore %>_<%= resource_var_name.pluralize %>_id_detailed<%= resource_var_name %>_200'
end
<%- end -%><%- if class_is_defined?("Create#{class_name}") -%>
test 'should create <%= resource_var_name %>' do
assert_difference -> { <%= class_name %>.count } do
post_<%= resource_var_name %>
end
assert_response :created
validate_json_request_body '/<%= resource_var_name %>/request_post_<%= domain_name.underscore %>_<%= resource_var_name.pluralize %>_create<%= resource_var_name %>'
validate_json_response_body '/<%= resource_var_name %>/post_<%= domain_name.underscore %>_<%= resource_var_name.pluralize %>_detailed<%= resource_var_name %>_201'
end
<%- end -%><%- if class_is_defined?("Update#{class_name}") -%>
test 'should update <%= resource_var_name %>' do
put_<%= resource_var_name %>
assert_response :success
validate_json_request_body '/<%= resource_var_name %>/request_put_<%= domain_name.underscore %>_<%= resource_var_name.pluralize %>_id_update<%= resource_var_name %>'
validate_json_response_body '/<%= resource_var_name %>/put_<%= domain_name.underscore %>_<%= resource_var_name.pluralize %>_id_detailed<%= resource_var_name %>_200'
assert_not_equal @<%= resource_var_name %>.attributes, @<%= resource_var_name %>.reload.attributes
end
<%- end -%><%- if class_is_defined?("Destroy#{class_name}") -%>
test 'should destroy <%= resource_var_name %>' do
assert_difference -> { <%= class_name %>.count }, -1 do
delete <%= domain_name.underscore %>_<%= resource_var_name %>_path(@<%= resource_var_name %>)
end
assert_response :no_content
end
<%- end -%><%- if class_is_defined?("Get#{class_name.pluralize}") -%>
test 'anonymous should not get <%= resource_var_name.pluralize %>' do
logout @user
get <%= domain_name.underscore %>_<%= resource_var_name.pluralize %>_path
assert_response :unauthorized
end
<%- end -%><%- if class_is_defined?("Get#{class_name}") -%>
test 'anonymous should not get <%= resource_var_name %>' do
logout @user
get <%= domain_name.underscore %>_<%= resource_var_name %>_path(@<%= resource_var_name %>)
assert_response :unauthorized
end
<%- end -%><%- if class_is_defined?("Create#{class_name}") -%>
test 'anonymous should not create <%= resource_var_name %>' do
logout @user
assert_no_difference -> { <%= class_name %>.count } do
post_<%= resource_var_name %>
end
assert_response :unauthorized
end
<%- end -%><%- if class_is_defined?("Update#{class_name}") -%>
test 'anonymous should not update <%= resource_var_name %>' do
logout @user
put_<%= resource_var_name %>
assert_response :unauthorized
assert_equal @<%= resource_var_name %>.attributes, @<%= resource_var_name %>.reload.attributes
end
<%- end -%><%- if class_is_defined?("Destroy#{class_name}") -%>
test 'anonymous should not destroy <%= resource_var_name %>' do
logout @user
assert_no_difference '<%= class_name %>.count' do
delete <%= domain_name.underscore %>_<%= resource_var_name %>_path(@<%= resource_var_name %>)
end
assert_response :unauthorized
end
<%- end -%><%- if class_is_defined?("Create#{class_name}") -%>
def post_<%= resource_var_name %>
post <%= domain_name.underscore %>_<%= resource_var_name.pluralize %>_path, params: {
<%= resource_var_name %>: attributes_for(:<%= resource_var_name %>)
}
end
<%- end -%><%- if class_is_defined?("Update#{class_name}") -%>
def put_<%= resource_var_name %>
put <%= domain_name.underscore %>_<%= resource_var_name %>_path(@<%= resource_var_name %>), params: {
<%= resource_var_name %>: attributes_for(:<%= resource_var_name %>)
}
end
<%- end -%>
end
<% end -%>
Documentation creation
def create_documentation_on_pericles
config = YAML.load_file('.pericles.yml')
project_id = config['project_id']
session = config['session']
client = Pericles::Client.new(base_url: 'https://ad-pericles.herokuapp.com', project_id: project_id, session: session)
json = begin
FactoryBot.attributes_for(resource_var_name.to_sym)
rescue KeyError
FactoryBot.find_definitions
FactoryBot.attributes_for(resource_var_name.to_sym)
end
response = client.create_resource(name: class_name, json: json)
response = JSON.parse(response.body)
resource_id = response['resource']['id']
default_resource_representation_id = response['resource']['resource_representations'].first['id']
attributes_ids = response['resource']['resource_attributes'].map { |data| data['id'] }
client.delete_resource_representation(resource_id: resource_id, representation_id: default_resource_representation_id)
detailed_representation = client.create_resource_representation(resource_id: resource_id, representation_name: "Detailed#{class_name}", attributes_ids: attributes_ids)
short_representation = client.create_resource_representation(resource_id: resource_id, representation_name: "Short#{class_name}", attributes_ids: attributes_ids)
if class_is_defined?("Get#{class_name}")
show_route = client.create_route(route_url: "/#{collection_var_name}/:id", http_method: 'GET', resource_id: resource_id)
client.create_response(route_id: show_route['route']['id'], status_code: 200, resource_representation_id: detailed_representation['resource_representation']['id'], is_collection: false, root_key: resource_var_name)
end
if class_is_defined?("Get#{class_name.pluralize}")
index_route = client.create_route(route_url: "/#{collection_var_name}", http_method: 'GET', resource_id: resource_id)
client.create_response(route_id: index_route['route']['id'], status_code: 200, resource_representation_id: short_representation['resource_representation']['id'], is_collection: true, root_key: collection_var_name)
end
if class_is_defined?("Create#{class_name}")
create_representation = client.create_resource_representation(resource_id: resource_id, representation_name: "Create#{class_name}", attributes_ids: attributes_ids)
create_route = client.create_route(route_url: "/#{collection_var_name}", http_method: 'POST', resource_id: resource_id, request_root_key: resource_var_name, request_resource_representation_id: create_representation['resource_representation']['id'])
client.create_response(route_id: create_route['route']['id'], status_code: 201, resource_representation_id: detailed_representation['resource_representation']['id'], is_collection: false, root_key: resource_var_name)
end
if class_is_defined?("Update#{class_name}")
update_representation = client.create_resource_representation(resource_id: resource_id, representation_name: "Update#{class_name}", attributes_ids: attributes_ids)
update_route = client.create_route(route_url: "/#{collection_var_name}/:id", http_method: 'PUT', resource_id: resource_id, request_root_key: resource_var_name, request_resource_representation_id: update_representation['resource_representation']['id'])
client.create_response(route_id: update_route['route']['id'], status_code: 200, resource_representation_id: detailed_representation['resource_representation']['id'], is_collection: false, root_key: resource_var_name)
end
if class_is_defined?("Destroy#{class_name}")
delete_route = client.create_route(route_url: "/#{collection_var_name}/:id", http_method: 'DELETE', resource_id: resource_id)
client.create_response(route_id: delete_route['route']['id'], status_code: 204, resource_representation_id: nil, is_collection: false, root_key: resource_var_name)
end
end
Interactors
Our interactors are straightforward and the associated tests do nothing that you would not expect.
class GetProjects < BaseInteractor
def call(initiator:, params: {})
projects = @authorizer.policy_scope(Project, initiator: initiator)
projects = projects.ransack(params[:q]).result
projects = projects.page(params[:page])
Success(projects)
end
end
class GetProjectsTest < ActiveSupport::TestCase
setup do
@user = create(:user)
@project = create(:project)
end
test 'should return collection of projects' do
result = GetProjects.new.call(initiator: @user)
assert_success result
projects = result.value!
assert_equal projects.klass, Project
assert_includes projects, @project
end
end
We use an @authorizer
that is a simple adapter around the Pundit interface. We use Ransack
for filtering and sorting and Kaminari for pagination. We don’t always use pagination but having it opt-in by default
is a good reminder to think about it. Since our interactors are plain ruby objects, it is very easy for new
developers to edit the current behaviour.
Templates code
<% module_namespacing do -%>
class Get<%= class_name.pluralize %> < BaseInteractor
def call(initiator:, params: {})
<%= collection_var_name %> = @authorizer.policy_scope(<%= class_name %>, initiator: initiator)
<%= collection_var_name %> = <%= collection_var_name %>.ransack(params[:q]).result
<%= collection_var_name %> = <%= collection_var_name %>.page(params[:page])
Success(<%= collection_var_name %>)
end
end
<% end -%>
require 'test_helper'
<% module_namespacing do -%>
class Get<%= class_name.pluralize %>Test < ActiveSupport::TestCase
setup do
@user = create(:user)
@<%= resource_var_name %> = create(:<%= resource_var_name %>)
end
test 'should return collection of <%= resource_var_name.pluralize %>' do
result = Get<%= class_name.pluralize %>.new.call(initiator: @user)
assert_success result
<%= resource_var_name.pluralize %> = result.value!
assert_equal <%= resource_var_name.pluralize %>.klass, <%= class_name %>
assert_includes <%= resource_var_name.pluralize %>, @<%= resource_var_name %>
end
end
<% end -%>
Serializer, Policies, Factories
class DetailedProjectSerializer < ActiveModel::Serializer
attributes(
:id,
# ...
)
end
class ProjectPolicy < ApplicationPolicy
class Scope < Scope
def resolve
super
end
end
def permitted_attributes
[
:name,
# ...
]
end
end
FactoryBot.define do
factory :project, class: Project do
company
offer_type
name { 'name' }
always_active { true }
start_date { Date.current }
number_of_days_sold { 42.0 }
# ...
end
end
Serializers, policies and factories are generated with all attributes. It is up to the developer to remove some if required. We find it is a lot easier to remove attributes than to add them as you cannot add typos when removing attributes.
Templates code
<% module_namespacing do -%>
class Detailed<%= class_name %>Serializer < ActiveModel::Serializer
attributes(<% database_columns.each do |column| %>
:<%= column.name -%>,<% end %>
)
end
<% end -%>
<% module_namespacing do -%>
class <%= class_name %>Policy < ApplicationPolicy
class Scope < Scope
def resolve
super
end
end
def permitted_attributes
[<% database_columns.reject { |column| %w(updated_at created_at id).include?(column.name) }.each do |column| %>
:<%= column.name -%>,<% end %>
]
end
end
<% end -%>
FactoryBot.define do
factory :<%= resource_var_name %>, class: <%= name %> do<% required_belongs_to_relations.each do |column| %>
<%= column.name.gsub(/_id$/, '') -%>
<% end %><% primitive_non_nullable_columns.each do |column| %>
<%= column.name %> { <%= fixture_value(column) %> }<% end %>
end
end
Conclusion
We chose to use json schemas, interactors and domains to write quality code and maintainable Rails applications. By using code generation to bootstrap a REST API, we don’t need to write all the boilerplate resulting from our (architectural) decisions and can focus on the business logic of our app.