Introduction
Hello, devs! 👋
Welcome to the second part of our journey creating APIs with Grape and Rails. In Part 1, we covered the basics of setting up a Grape API and how to write effective tests. Today, we'll elevate our API by implementing two powerful gems that can significantly improve your development workflow:
- dry-validation - For robust parameter validation
- interactor - For clean, organized business logic
I'll also share a custom helper I created to simplify working with dry-validation in Grape APIs. It's something I developed quickly for an MVP, but it worked perfectly for our needs.
Setting Up Our Project
Let's add the necessary gems to our Gemfile
:
gem 'grape'
gem 'grape-swagger'
gem 'grape-entity'
gem 'grape-swagger-entity'
gem 'rswag-ui'
gem 'rack-cors'
# Add these new gems
gem 'dry-validation', '~> 1.10'
gem 'interactor', '~> 3.1'
Run bundle install
to install the dependencies.
The Power of Contracts with dry-validation
Instead of relying solely on Grape's built-in parameter validation, we can use dry-validation contracts to create more robust validation rules. Let's create two contracts for our event resource:
# app/contracts/events_create_contract.rb
# frozen_string_literal: true
class EventsCreateContract < Dry::Validation::Contract
params do
# @title = Event title, at least 3 characters long
required(:title).filled(:string, min_size?: 3)
# @description = Detailed description of the event
optional(:description).maybe(:string)
# @event_type = Type of event (business or birthday)
required(:event_type).filled(:string, included_in?: %w[business birthday])
# @number_of_people = Expected number of attendees
optional(:number_of_people).maybe(:integer, gt?: 0)
# @special_requests = Any special requirements for the event
optional(:special_requests).maybe(:string)
end
rule(:number_of_people) do
key.failure("must be provided for business events") if values[:event_type] == "business" && value.nil?
end
end
Notice how we're using comments with a special format (# @parameter_name = description
) - this is important for the custom helper we'll discuss later.
For update operations, we create a separate contract:
# app/contracts/events_update_contract.rb
# frozen_string_literal: true
class EventsUpdateContract < Dry::Validation::Contract
params do
# @id = Event ID
required(:id).filled(:integer)
# @title = Event title, at least 3 characters long
required(:title).filled(:string, min_size?: 3)
# @description = Detailed description of the event
optional(:description).maybe(:string)
# @event_type = Type of event (business or birthday)
required(:event_type).filled(:string, included_in?: %w[business birthday])
# @number_of_people = Expected number of attendees
optional(:number_of_people).maybe(:integer, gt?: 0)
# @special_requests = Any special requirements for the event
optional(:special_requests).maybe(:string)
end
end
You might be wondering why I created two separate contracts that look very similar. While it's tempting to follow the DRY principle and reuse the same contract, in practice, I've found that having separate contracts for different operations (create vs update) provides more flexibility. It's a pattern derived from the concept of DTOs (Data Transfer Objects), and we'll dive deeper into this in a future post!
The ContractToParams Helper: A Bridge Between dry-validation and Grape
One of the challenges when using dry-validation with Grape is that you end up defining parameters twice: once in the contract and once in the Grape endpoint. To solve this, I created a helper that automatically generates Grape parameter definitions from dry-validation contracts:
# app/api/helpers/contract_to_params.rb
# frozen_string_literal: true
module Helpers
module ContractToParams
extend Grape::API::Helpers
def self.generate_params(contract_class, options = {})
annotations = extract_annotations(contract_class)
contract = contract_class.new
param_type = options[:param_type] || 'body'
contract.schema.rules.each_with_object({}) do |(name, rule), params_hash|
type = extract_type(rule)
required = !optional_field?(rule)
description = annotations[name] || name.to_s.humanize
params_hash[name] = {
type: type,
desc: description,
required: required,
documentation: { param_type: param_type }
}
end
end
def self.extract_annotations(contract_class)
# Read the source file content using the class path
file_path = contract_class.name.underscore
paths_to_try = [
Rails.root.join("app/contracts/", "#{file_path}.rb"),
Rails.root.join("app/api/contracts/", "#{file_path}.rb"),
Rails.root.join("app/models/#{file_path}.rb")
]
source_path = paths_to_try.find { |path| File.exist?(path) }
return {} unless source_path
source_lines = File.readlines(source_path)
annotations = {}
field_pattern = /\s*(optional|required)\(:([\w_]+)\)/
source_lines.each_with_index do |line, index|
next unless line.match?(field_pattern)
field_name = line.match(field_pattern)[2]
previous_line = source_lines[index - 1]
if previous_line&.match?(/^\s*#\s*@#{field_name}\s*=\s*(.+)/)
annotations[field_name.to_sym] = previous_line.match(/^\s*#\s*@#{field_name}\s*=\s*(.+)/)[1].strip
end
end
annotations
end
def self.extract_type(rule)
predicates = extract_all_predicates(rule)
return [TrueClass, FalseClass] if predicates.include?(:bool?)
return Integer if predicates.include?(:int?) || predicates.include?(:integer?)
return Float if predicates.include?(:float?)
return Array if predicates.include?(:array?)
return Hash if predicates.include?(:hash?)
return Date if predicates.include?(:date?)
return Time if predicates.include?(:time?)
String
end
def self.extract_all_predicates(rule)
predicates = []
case rule
when Dry::Logic::Operations::And, Dry::Logic::Operations::Or
rule.rules.each do |sub_rule|
predicates.concat(extract_all_predicates(sub_rule))
end
when Dry::Logic::Operations::Key
predicates.concat(extract_all_predicates(rule.rules.first))
when Dry::Logic::Operations::Implication
predicates << :optional
rule.rules.each do |sub_rule|
predicates.concat(extract_all_predicates(sub_rule))
end
when Dry::Logic::Rule::Predicate
predicates << rule.predicate.name if rule.predicate&.name
end
predicates.uniq
end
def self.optional_field?(rule)
return true if rule.is_a?(Dry::Logic::Operations::Implication)
if rule.is_a?(Dry::Logic::Operations::And)
rule.rules.any? do |sub_rule|
sub_rule.is_a?(Dry::Logic::Operations::Implication)
end
else
false
end
end
end
end
This helper was created in a hurry and I work on improving it whenever I can, but currently it meets 99% of our project's needs.
As you can see in the images, our helper improves our documentation as shown below:
Organizing Business Logic with Interactors
If you speak Portuguese, I highly recommend taking Jackson Pires' course at https://videosdeti.com.br/ - he teaches how to use the Interactors gem effectively, among other things.
The interactor gem helps us divide business logic into small, focused classes. Let's see how we implemented this for our events resource:
# app/services/events/interactors/create_event.rb
# frozen_string_literal: true
module Events
module Interactors
class CreateEvent
include Interactor
delegate :params, to: :context
def call
event = Event.new(context.params)
if event.save
context.event = event
else
context.fail!(errors: event.errors)
end
end
end
end
end
This interactor has a single responsibility: creating an event in the database.
Next, we create an interactor to send a notification after the event is created:
# app/services/events/interactors/send_notification.rb
# frozen_string_literal: true
module Events
module Interactors
class SendNotification
include Interactor
delegate :event, to: :context
def call
Rails.logger.info("Event '#{event.title}' was created successfully!")
# Here you could send an actual notification via email, SMS, etc.
# For demonstration purposes, we're just logging a message
end
end
end
end
Finally, we create an organizer to coordinate these interactors:
# app/services/events/organizers/register.rb
# frozen_string_literal: true
module Events
module Organizers
class Register
include Interactor::Organizer
organize ::Events::Interactors::CreateEvent,
::Events::Interactors::SendNotification
around do |interactor|
ActiveRecord::Base.transaction do
interactor.call
raise ActiveRecord::Rollback if context.failure?
end
end
end
end
end
The organizer does two important things:
- Defines the order of execution for the interactors using
organize
- Wraps the execution in a transaction, ensuring that if any interactor fails, all database changes are rolled back
What's amazing about this pattern is how it makes business logic easy to understand, test, and maintain.
Integrating With Our Grape API
Now, let's put everything together and see how our Grape API looks with these improvements:
# app/api/v1/events.rb
# frozen_string_literal: true
module V1
class Events < Grape::API
# this is just an example for this post, I recommend better planning and creating something like
# FormatErrorHelpers and calling
# helpers ::Helpers::FormatErrorHelpers
helpers do
def format_errors(errors)
case errors
when ActiveModel::Errors
errors.messages.transform_values { |messages| messages.join(", ") }
when Hash
errors
else
{ base: errors.to_s }
end
end
end
resource :events do
desc "Get all events", {
success: ::Entities::EventResponse,
failure: [
{ code: 401, message: "Unauthorized" }
],
tags: ["events"]
}
get do
@events = Event.all
present @events, with: ::Entities::EventResponse
end
desc "Get a specific event", {
success: ::Entities::EventResponse,
failure: [
{ code: 401, message: "Unauthorized" },
{ code: 404, message: "Not Found" }
],
params: {
id: { type: Integer, desc: "Event ID" }
},
tags: ["events"]
}
get ":id" do
@event = Event.find(params[:id])
present @event, with: ::Entities::EventResponse
rescue ActiveRecord::RecordNotFound
error!({ error: "Event not found" }, 404)
end
desc "Create a new event", {
success: ::Entities::EventResponse,
failure: [
{ code: 401, message: "Unauthorized" },
{ code: 422, message: "Unprocessable Entity" }
],
params: ::Helpers::ContractToParams.generate_params(EventsCreateContract)
}
post do
contract = EventsCreateContract.new
result = contract.call(params)
error!({ errors: result.errors.to_h }, 422) if result.failure?
service_result = ::Events::Organizers::Register.call(params: result.to_h)
if service_result.success?
present service_result.event, with: ::Entities::EventResponse, status: :created
else
error!({ errors: format_errors(service_result.errors) }, 422)
end
end
desc "Update an existing event", {
success: ::Entities::EventResponse,
failure: [
{ code: 401, message: "Unauthorized" },
{ code: 404, message: "Not Found" },
{ code: 422, message: "Unprocessable Entity" }
],
params: ::Helpers::ContractToParams.generate_params(EventsUpdateContract),
# Even though the fields are practically the same, we know that details make all the difference!
# I recommend using EventsUpdateContract - repeating the fields from EventsCreateContract, not everything needs to be 100% DRY (Don't Repeat Yourself).
# And this applies to DTOs (yes, our contracts too!).
# But feel free to use inheritance or modules to avoid repeating fields; the important thing is that you and your team agree and have fun.
#
# And if you're curious about why I don't use abstraction in DTOs... maybe we'll discuss that in a future post!
tags: ["events"]
}
put ":id" do
contract = EventsUpdateContract.new
result = contract.call(params)
error!({ errors: result.errors.to_h }, 422) if result.failure?
@event = ::Event.find(params[:id])
@event.assign_attributes(result.to_h)
if @event.save
present @event, with: ::Entities::EventResponse
else
error!({ errors: format_errors(@event.errors) }, 422)
end
rescue ActiveRecord::RecordNotFound
error!({ error: "Event not found" }, 404)
end
desc "Delete an event", {
failure: [
{ code: 401, message: "Unauthorized" },
{ code: 404, message: "Not Found" }
],
params: {
id: { type: Integer, desc: "Event ID" }
},
tags: ["events"]
}
delete ":id" do
@event = Event.find(params[:id])
if @event.destroy
{ success: true, message: "Event successfully deleted" }
else
error!({ errors: format_errors(@event.errors) }, 422)
end
rescue ActiveRecord::RecordNotFound
error!({ error: "Event not found" }, 404)
end
end
end
end
Notice how our API is cleaner and more organized:
- We use
::Helpers::ContractToParams.generate_params(EventsCreateContract)
to automatically generate parameter documentation. - We validate parameters with dry-validation contracts.
- We delegate business logic to interactors.
And here's our main Grape API configuration:
# app/api/v1/api_grape.rb
# frozen_string_literal: true
class V1::ApiGrape < Grape::API
version "v1", using: :path
format :json
default_format :json
mount V1::Events
add_swagger_documentation(
api_version: "v1",
hide_documentation_path: true,
mount_path: "/swagger_doc",
hide_format: true,
info: {
title: "Events API",
description: "API for event management",
contact_name: "Support Team",
contact_email: "[email protected]"
},
security_definitions: {
Bearer: {
type: "apiKey",
name: "Authorization",
in: "header",
description: 'Enter "Bearer" followed by your token. Example: Bearer abc123'
}
},
security: [{ Bearer: [] }]
)
end
Benefits of This Approach
We use contracts (a kind of DTO) for business validation in the request rather than in the model. We use models only to represent database rules (in the project my friend and I are working on, even with validation in the models, we also have validation rules in the database itself so the project isn't dependent on a framework).
After implementing these patterns in our project, we noticed several benefits:
Using dry-validation Contracts
- Separation of concerns: Validation is completely separate from the API and models.
- More powerful validations: We can express complex business rules clearly.
- Reusability: Contracts can be used in different contexts, not just in the API.
- Automatic documentation: With our custom helper, documentation is generated automatically.
Using Interactors
- Modular code: Each interactor has a single responsibility.
- Easy to test: Interactors can be tested in isolation.
- Transaction control: The organizer takes care of wrapping everything in a transaction.
- Readability: It's easy to understand the business flow by looking at the organizer.
One thing I'd like to highlight is my decision to use separate contracts for creation and updating. Although they contain similar fields, I believe that keeping our Contracts (a kind of DTO) separate is a good practice. Validation needs for creation and updating often diverge over time, and having separate contracts gives us flexibility to evolve.
Conclusion
In this post, we explored how to elevate our Grape API using dry-validation for parameter validation and interactors for organizing business logic. I also shared a custom helper I developed to facilitate integration between dry-validation and Grape.
Although some of these solutions were developed quickly for an MVP, they demonstrate how we can create more robust and maintainable APIs with relatively little effort.
In the next post, we'll talk about Policies and Authorization with login using JWT tokens. Stay tuned!
Have you used dry-validation or interactors in your Rails projects? What was your experience? Leave a comment below!
Repository: https://github.com/rodrigonbarreto/event_reservation_system/tree/mvp_sample_pt2
References:
Rodrigo Nogueira Ruby on Rails Developer since 2015
Postgraduate in Software Engineering (PUC-RJ) & passionate about technology since 2011.
Top comments (0)