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:

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.