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