Building RESTful microservices with Roda template

Benefits

  • Provides high-level abstractions
  • Enforces an intuitive code structure and naming convention
  • Lets you focus on your application code, minimize bugs and improve the maintainability
  • Provides out-of-the-box testing approaches

What problems does solve this approach?

  • Not clear abstractions
  • Naming convention absence, naming hell
  • Unstructured application code-base
  • Ugly tests with low coverage
  • Dead code copy-paste from app to another app

Basic abstraction layers

OPERATION

CONTRACT

service object

form object

MODEL
data model

STEP

service object

ENTITY
struct object

MAPPER
transformation obj

QUERY
query object

SERIALIZER
json

SERVICE
poro

ROUTE
roda-based route

QUEUE
background job

Application code structure

├── contracts
│   ├── create_post.rb
├── db
│   ├── migrations
│   │   ├── 001_setup.rb
├── entities
│   ├── client_error_response.rb
├── mappers
│   ├── create_post
│   │	├── response_get_post.rb
├── models
│   ├── post.rb
├── operations
│   ├── create_post.rb
├── queues
│   ├── notify_subscribers.rb
├── queries
│   ├── find_post.rb
├── routes
│   ├── post.rb
├── serializers
│   ├── post.rb
├── services
│   ├── build_client_error_response.rb
├── steps
│   ├── create_post
│   │   ├── build_contract.rb
├── tasks

Code structure organizes by abstraction and then by concept.

── specs
   ├── contracts
   ├── entities
   ├── factories
   ├── fixtures
   │   ├── schemas
   │   ├── vcr_cassettes
   ├── helpers
   ├── mappers
   ├── models
   ├── operations
   ├── queues
   ├── queries
   ├── requests
   ├── serializers
   ├── services
   ├── steps
   ├── support
   ├── spec_helper.rb

ROUTE

OPERATION

STEP

CONTRACT

STEP

MODEL

QUERY

MAPPER

SERIALIZER

request params

result

response

request

result

The life circle of create post endpoint

Route

Concept, benefits, use cases

ROUTE

OPERATION

ENDPOINT

params

params

result

request

render result with http status

response

# routes/post.rb

class Post
  hash_routes.on('post') do |request|
    request.post do
      Operations::CreatePost.call(params) do |monad|
        monad.success { |result| respond_with(201, result[:model], Serializers::Post) }
        monad.failure { |errors| build_error_response(errors, Serializers::ClientError) }
      end
    end

    request.get do
      Operations::GetPost.call(params) do |monad|
        monad.success { |result| respond_with(200, result[:model], Serializers::Post) }
        monad.failure { |errors| build_error_response(errors, Serializers::ClientError) }
      end
    end
  end
end

Routes definition example

# spec/requests/create_spec.rb

RSpec.describe 'post', type: :request do
  describe 'POST #create' do
    before do
      request(
        method: :post,
        resource: 'post',
        params: params.to_json,
        headers: headers(session: create(:session, :admin).key)
      )
    end

    describe 'Success' do
      describe 'Created' do
        let(:params) { { data: { attributes: { title: 'Some title' } } } }

        it 'renders new post' do
          expect(response).to be_created
          expect(resource_type(response)).to eq('post')
          expect(response).to match_json_schema('post')
        end
      end
    end

    describe 'Failure' do
      describe 'Unprocessable Entity' do
        let(:params) { { data: { attributes: {} } } }

        include_examples('renders error response', :be_unprocessable)
      end
    end
  end
end

Route test example (request test)
Please note, we are testing here http statuses and responses only!

To build OpenAPI specification just run this rake task:

bundle exec rake doc:openapi
# or
bundle exec rake doc:html

Operation

Concept, benefits, use cases

An operation is a service object

The flow pipetree is a mix of the Either monad and Railway-oriented programming
 

An operation is not a monolithic god object, but a composition of many stakeholders.

An operation is just a wrapper over Strum::Service and Strum::Pipe

Operation possible steps

# operations/create_post.rb

module Operations
  class CreatePost < Base::Operation
    steps shared_step::DeserializeJson, # shortcut to Steps::Shared::DeserializeJson
          step::BuildContract,          # shortcut to Steps::CreatePost::BuildContract
          step::PrepareParamsCreatePost,
          shared_step::PerformThirdPartyRequest,
          Steps::CreatePost::PersistPost # you can use full namespace instead shortcut
  end
end

Operation definition example

# spec/operations/create_post_spec.rb

RSpec.describe Operations::CreatePost, type: :operation do
  it_behaves_like 'operation'

  describe '.call' do
    subject(:operation) { prepare_operation(described_class, cassette:, params:) }

    describe 'when success' do
      it 'creates new post, persists to db' do
        expect { operation }
          .to include_steps(
            Steps::Shared::DeserializeJson,
            Steps::CreatePost::BuildContract,
            Steps::CreatePost::PrepareParamsCreatePost,
            Steps::CreatePost::PerformThirdPartyRequest,
            Steps::CreatePost::PersistPost
          )
          .and be_successful
          .and change(Models::Post, :count).by(1)
        expect(operation_context[:session]).to eq(session)
        expect(operation_context[:model].title).to eq(title)
      end
    end
  end
end

Operation testing example, successful case

# spec/operations/create_post_spec.rb

RSpec.describe Operations::CreatePost, type: :operation do
  it_behaves_like 'operation'

  describe '.call' do
    subject(:operation) { prepare_operation(described_class, cassette:, params:) }

    describe 'when failure' do
      context 'when bad params' do
        let(:params) { prepare_operation_params(session: {}) }

        it 'returns operation error context' do
          expect(operation).to be_failed
          expect(operation_context).to eq(
            unprocessable_entity: [
              [:title, %w[attribute_is_not_filled]]
            ]
          )
        end
      end
    end
  end
end

Operation testing example, failure case

Step

Concept, benefits, use cases

Step is an atomic service object

A step is an operation's component

A step not a god object. It should cover minimal logic flow.

A step is just a wrapper over Strum::Service

# steps/create_post/build_contract.rb

module Steps
  module CreatePost
    class BuildContract < Base::Step
      def audit
        add_errors(unprocessable_entity: contract.errors.to_h) if contract.failure?
      end

      def call
        output(
          **Mappers::Shared::PostContract.call(contract.to_h)
        )
      end

      private

      def contract
        @contract ||= Contracts::CreatePost.call(
          title: input[:title]
        )
      end
    end
  end
end

Step definition example

# spec/steps/create_post/build_contract_spec.rb

RSpec.describe Steps::CreatePost::BuildContract, type: :step do
  it_behaves_like 'step'

  describe '.call' do
    subject(:step) { prepare_step(described_class, params:) }

    describe 'when success' do
      let(:params) { { title: } }

      it 'builds contract' do
        expect(Contracts::CreatePost)
          .to receive(:call)
          .and_call_original
        expect(Mappers::Shared::PostContract)
          .to receive(:call)
          .and_call_original
        expect(step).to be_successful
        expect(step_context).to include(
          post_title: title
        )
      end
    end
  end

Step testing example, successful case

# spec/steps/create_post/build_contract_spec.rb

RSpec.describe Steps::CreatePost::BuildContract, type: :step do
  it_behaves_like 'step'

  describe '.call' do
    subject(:step) { prepare_step(described_class, params:) }

    describe 'when failure' do
      context 'when the required parameters are missing' do
        let(:failed_contract) do
          instance_double(
            'FailedContract',
            failure?: true,
            errors: { example: :error }
          )
        end

        it 'returns step error context' do
          expect(Contracts::CreatePost)
            .to receive(:call)
            .with(title: nil)
            .and_return(failed_contract)
          expect(step).to be_failed
          expect(step_context).to include(unprocessable_entity: [%i[example error]])
        end
      end
    end
  end
end

Step testing example, failure case

Contract

Concept, benefits, use cases

Contract is a form object

Contract is a validation layer

All request's validations of params/conditions should process inside this layer.

Contract is just a wrapper over Dry::Validation::Contract

# contracts/create_post.rb

module Contracts
  class CreatePost < Base::Contract
    params do
      required(:title).filled(:string)
    end
  end
end

Contract definition example

# spec/contracts/create_post_spec.rb

RSpec.describe Contracts::CreatePost do
  it_behaves_like 'contract'

  describe '.call' do
    subject(:contract) { described_class.call(**params) }

    let(:params) { { title: 'Some Post Title' } }

    describe 'when success' do
      it { is_expected.to be_success }
    end
  end
end

Contract testing example, successful case

# spec/contracts/create_post_spec.rb

RSpec.describe Contracts::CreatePost do
  it_behaves_like 'contract'

  describe '.call' do
    subject(:contract) { described_class.call(**params) }

    let(:params) { { title: 'Some Post Title' } }


    describe 'when failure' do
      context 'without params' do
        let(:params) { {} }

        it do
          expect(contract)
            .to be_failure
            .and have_validation_errors(title: :attribute_is_missing)
        end
      end

      context 'with empty params' do
        let(:params) { { title: '' } }

        it do
          expect(contract)
            .to be_failure
            .and have_validation_errors(title: :attribute_is_not_filled)
        end
      end

      context 'with wrong types' do
        let(:params) { { title: {} } }

        it do
          expect(contract)
            .to be_failure
            .and have_validation_errors(title: :attribute_is_not_a_string)
        end
      end
    end
  end
end

Contract testing example, failure case

Model

Concept, benefits, use cases

Model is an object relational mapper built on top of Sequel ORM

Model datasets return rows as model instances
# models/post.rb

module Models
  class Post < Sequel::Model
    plugin :timestamps, create: :created_at, update: :updated_at
  end
end

Model definition example

# spec/models/post_spec.rb

RSpec.describe Models::Post do
  it_behaves_like 'model'

  describe 'fields' do
    it { is_expected.to have_column(:title, type: :string) }
    it { is_expected.to have_column(:created_at, type: :datetime) }
    it { is_expected.to have_column(:updated_at, type: :datetime) }
  end
end

Model testing example

bundle exec rake sequel:create
bundle exec rake sequel:migrate
bundle exec rake sequel:reset
bundle exec rake sequel:drop
bundle exec rake sequel:rollback
bundle exec rake sequel:version
bundle exec rake sequel:annotate
bundle exec rake db:create RACK_ENV=test
bundle exec rake db:drop RACK_ENV=test

DB manipulations

Entity

Concept, benefits, use cases

Entity is typed struct object

Use case: the necessity of object with typed fields

An entity is based on Dry::Struct

# entities/client_error_response.rb

module Entities
  class ClientErrorResponse < Base::Entity
    attribute :attribute, Types::Symbol.optional.default(nil)
    attribute :code, Types::String.optional.default(nil)
    attribute :message, Dry::Struct.meta(omittable: true) do
      attribute :en_message, Types::Strict::String.optional.default(nil)
      attribute :ar_message, Types::Strict::String.optional.default(nil)
    end
  end
end

Entity definition example

# spec/entities/client_error_response_spec.rb

RSpec.describe Entities::ClientErrorResponse do
  it_behaves_like 'entity'

  describe '.call' do
    subject(:entity) { described_class.call(params) }

    context 'with message context' do
      let(:params) { attributes_for(:client_error_response) }

      it 'creates object with defined values of params' do
        expect(entity.attribute).to eq(params[:attribute])
        expect(entity.code).to eq(params[:code])
        message = entity.message
        expect(message.en_message).to eq(params.dig(:message, :en_message))
        expect(message.ar_message).to eq(params.dig(:message, :ar_message))
      end
    end

    context 'without message context' do
      let(:params) { attributes_for(:client_error_response, :without_message_context) }

      it 'creates object with defined values of params, default messages context' do
        expect(entity.attribute).to eq(params[:attribute])
        expect(entity.code).to eq(params[:code])
        message = entity.message
        expect(message.en_message).to be_nil
        expect(message.ar_message).to be_nil
      end
    end
  end
end

Entity testing example

Service

Concept, benefits, use cases

Service is a PORO

Use case: some technical classes without app business logic
# services/filter.rb

module Services
  module Filter
    def self.call(filter:, data:)
      new(filter:, data:).call
    end
  end
end

Service definition example

Query

Concept, benefits, use cases

Query is query object

Use case: simple or complex DB queries

Should return sequel relation

# queries/find_post.rb

module Queries
  class FindPost < Base::Query
    def call
      Models::Post.find(**params)
    end
  end
end

Entity definition example

# spec/queries/find_post.rb

RSpec.describe Queries::FindPost do
  it_behaves_like 'query'

  describe '#call' do
    subject(:query) { described_class.call(**params) }

    let(:params) { { id: expected_model.id } }

    context 'when model found' do
      let!(:expected_model) { create(:post, **params) }

      it 'returns found model' do
        expect(Models::Post).to receive(:find).with(**params).and_call_original
        expect(query).to eq(expected_model)
      end
    end

    context 'when model not found' do
      it do
        expect(Models::Post).to receive(:find).with(**params).and_call_original
        expect(query).to be_nil
      end
    end
  end
end

Query testing example

Mapper

Concept, benefits, use cases

Mapper is transformation object

Use case: hash mutations

Mapper is based on Dry::Transformer::Pipe

# mappers/get_posts/contract.rb

module Mappers
  module GetPosts
    class Contract < Base::Mapper
      define! do
        rename_keys tag: :target_post_tag
        map_value :pagination, ->(pagination_params) { Entities::Pagination.call(pagination_params) }
        map_value :filter, lambda { |filter_fields|
          Entities::Filter.call(
            filter_fields.transform_values do |filter|
              target_key = filter.keys.first
              { matcher: target_key, value: filter[target_key] }
            end
          )
        }
      end
    end
  end
end

Mapper definition example

# spec/mappers/get_posts/contract_spec.rb

RSpec.describe Mappers::GetPosts::Contract do
  it_behaves_like 'mapper'

  describe '.call' do
    subject(:mapper) { described_class.call(params) }

    let(:tag) { random_post_tag }
    let(:pagination_attributes) { attributes_for(:pagination) }
    let(:filter_attributes) { attributes_for(:filter) }
    let(:params) do
      {
        tag:,
        pagination: pagination_attributes,
        filter: build_filter_params(filter_attributes)
      }
    end

    it 'maps and coerces defined params' do
      expect(mapper).to include(
        target_post_tag: tag,
        pagination: create(:pagination, **pagination_attributes),
        filter: create(:filter, **filter_attributes)
      )
    end
  end
end

Mapper testing example

Serializer

Concept, benefits, use cases

Serializer is JavaScript Object Notation

Serializer is based on JSONAPI::Serializer

# serializers/post.rb

module Serializers
  class Post < Base::Serializer
    set_type :post
    attributes :title, :created_at, :updated_at
  end
end

Serializer definition example

# spec/serializers/post_spec.rb

RSpec.describe Serializers::Post do
  it_behaves_like 'serializer'

  describe 'serializer configuration' do
    it { expect(described_class.transform_method).to eq(:dasherize) }
    it { expect(described_class.record_type).to eq(:post) }
  end
end

Serializer test example

Building RESTful API with Roda template

By Vladislav Trotsenko

Building RESTful API with Roda template

  • 476