Introdução
Olá, devs! 👋
Bem-vindos à segunda parte da nossa jornada criando APIs com Grape e Rails. Na Parte 1, abordamos o básico da configuração de uma API Grape e como escrever testes eficazes. Hoje, vamos elevar o nível da nossa API implementando duas gems poderosas que podem melhorar significativamente seu fluxo de desenvolvimento:
- dry-validation - Para validação robusta de parâmetros
- interactor - Para uma lógica de negócios limpa e organizada
Também vou compartilhar um helper personalizado que criei para simplificar o trabalho com dry-validation em APIs Grape. Foi algo que desenvolvi rapidamente para um MVP, mas funcionou perfeitamente para nossas necessidades.
Configurando Nosso Projeto
Vamos adicionar as gems necessárias ao nosso Gemfile
:
gem 'grape'
gem 'grape-swagger'
gem 'grape-entity'
gem 'grape-swagger-entity'
gem 'rswag-ui'
gem 'rack-cors'
# Adicione essas novas gems
gem 'dry-validation', '~> 1.10'
gem 'interactor', '~> 3.1'
Execute bundle install
para instalar as dependências.
O Poder dos Contratos com dry-validation
Em vez de depender apenas da validação de parâmetros integrada do Grape, podemos usar contratos do dry-validation para criar regras de validação mais robustas. Vamos criar dois contratos para nosso recurso de eventos:
# app/contracts/events_create_contract.rb
# frozen_string_literal: true
class EventsCreateContract < Dry::Validation::Contract
params do
# @title = Título do evento, com no mínimo 3 caracteres
required(:title).filled(:string, min_size?: 3)
# @description = Descrição detalhada do evento
optional(:description).maybe(:string)
# @event_type = Tipo de evento (business ou birthday)
required(:event_type).filled(:string, included_in?: %w[business birthday])
# @number_of_people = Número esperado de participantes
optional(:number_of_people).maybe(:integer, gt?: 0)
# @special_requests = Quaisquer requisitos especiais para o evento
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
Observe como estamos usando comentários com um formato especial (# @nome_do_parametro = descrição
) - isso é importante para o helper personalizado que discutiremos mais adiante.
Para operações de atualização, criamos um contrato separado:
# app/contracts/events_update_contract.rb
# frozen_string_literal: true
class EventsUpdateContract < Dry::Validation::Contract
params do
# @id = ID do evento
required(:id).filled(:integer)
# @title = Título do evento, com no mínimo 3 caracteres
required(:title).filled(:string, min_size?: 3)
# @description = Descrição detalhada do evento
optional(:description).maybe(:string)
# @event_type = Tipo de evento (business ou birthday)
required(:event_type).filled(:string, included_in?: %w[business birthday])
# @number_of_people = Número esperado de participantes
optional(:number_of_people).maybe(:integer, gt?: 0)
# @special_requests = Quaisquer requisitos especiais para o evento
optional(:special_requests).maybe(:string)
end
end
Você pode estar se perguntando por que criei dois contratos separados que parecem muito semelhantes. Embora seja tentador seguir o princípio DRY e reutilizar o mesmo contrato, na prática, descobri que ter contratos separados para diferentes operações (criar vs atualizar) oferece mais flexibilidade. É um padrão derivado do conceito de DTOs (Data Transfer Objects), e vamos nos aprofundar nisso em um post futuro!
O Helper ContractToParams: Uma Ponte Entre dry-validation e Grape
Um dos desafios ao usar dry-validation com Grape é que você acaba definindo parâmetros duas vezes: uma vez no contrato e outra vez no endpoint do Grape. Para resolver isso, eu criei um helper que gera automaticamente definições de parâmetros do Grape a partir de contratos do dry-validation:
# 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)
# Lê o conteúdo do arquivo fonte usando o path da classe
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
Este helper foi feito às pressas e sempre que posso trabalho em melhorias nele, mas atualmente supre 99% a necessidade do nosso projeto.
Como você vê nas fotos, o nosso helper ajuda a melhorar nossa documentação seguem as fotos abaixo
Organizando a Lógica de Negócios com Interactors
Se você fala português super recomendo fazer o curso do Jackson Pires no site https://videosdeti.com.br/ ele ensina a usar bem a gem Interactors entre outras coisas.
A gem interactor nos ajuda a dividir a lógica de negócios em pequenas classes focadas. Vamos ver como implementamos isso para nosso recurso de eventos:
# 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
Este interactor tem uma única responsabilidade: criar um evento no banco de dados.
Em seguida, criamos um interactor para enviar uma notificação após a criação do evento:
# 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!")
# Aqui você poderia enviar uma notificação real via email, SMS, etc.
# Para fins de demonstração, estamos apenas registrando uma mensagem
end
end
end
end
Finalmente, criamos um organizador para coordenar esses 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
O organizador faz duas coisas importantes:
- Define a ordem de execução dos interactors usando
organize
- Envolve a execução em uma transação, garantindo que, se qualquer interactor falhar, todas as mudanças no banco de dados sejam revertidas
O que é incrível sobre esse padrão é como ele torna a lógica de negócios fácil de entender, testar e manter.
Integrando Com Nossa API Grape
Agora, vamos juntar tudo e ver como nossa API Grape fica com essas melhorias:
# app/api/v1/events.rb
# frozen_string_literal: true
module V1
class Events < Grape::API
# este é um exemplo apenas pro post, recomendo planejar melhor e criar algo como
# FormatErrorHelpers e chamando
# 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),
# Mesmo que os campos sejam praticamente os mesmos, a gente sabe que os detalhes fazem toda a diferença!
# Recomendo usar o EventsUpdateContract – repetindo os campos do EventsCreateContract, nem tudo precisa ser 100% DRY (Don't Repeat Yourself).
# E isso vale para os DTOs (sim, nossos contracts também!).
# Mas fique à vontade para usar herança ou módulos para evitar repetir os campos; o importante é que você e sua equipe entrem em acordo e se divirtam.
#
# Ah, e se vocês tiverem curiosidade sobre por que eu não uso abstração em DTOs... quem sabe a gente discute isso num próximo 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
Observe como nossa API está mais limpa e organizada:
- Usamos
::Helpers::ContractToParams.generate_params(EventsCreateContract)
para gerar automaticamente a documentação de parâmetros. - Validamos os parâmetros com os contratos do dry-validation.
- Delegamos a lógica de negócios a interactors.
E aqui está nossa configuração principal da API Grape:
# 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: "contact@example.com"
},
security_definitions: {
Bearer: {
type: "apiKey",
name: "Authorization",
in: "header",
description: 'Enter "Bearer" followed by your token. Example: Bearer abc123'
}
},
security: [{ Bearer: [] }]
)
end
Benefícios Desta Abordagem
Usamos os contracts (uma espécie de DTO) para as validações de negócio no request e não no modelo, usamos os modelos apenas para representar as regras do banco de dados (no nosso projeto que eu e meu amigo estamos trabalhando, mesmo com validação nos modelos também temos as regras de validação no próprio banco de dados para o projeto não ser refém de um framework).
Depois de implementar esses padrões em nosso projeto, percebemos vários benefícios:
Usando Contratos dry-validation
- Separação de responsabilidades: A validação está completamente separada da API e dos modelos.
- Validações mais poderosas: Podemos expressar regras de negócios complexas de maneira clara.
- Reutilização: Os contratos podem ser usados em diferentes contextos, não apenas na API.
- Documentação automática: Com nosso helper personalizado, a documentação é gerada automaticamente.
Usando Interactors
- Código modular: Cada interactor tem uma única responsabilidade.
- Fácil de testar: Interactors podem ser testados isoladamente.
- Controle transacional: O organizador cuida de envolver tudo em uma transação.
- Legibilidade: É fácil entender o fluxo de negócios olhando para o organizador.
Uma coisa que eu gostaria de destacar é minha decisão de usar contratos separados para criação e atualização. Embora eles contenham campos semelhantes, eu acredito que manter os nossos Contracts (uma espécie de DTO) separados é uma boa prática. As necessidades de validação para criação e atualização muitas vezes divergem com o tempo, e ter contratos separados nos dá flexibilidade para evoluir.
Conclusão
Neste post, exploramos como elevar o nível da nossa API Grape usando dry-validation para validação de parâmetros e interactors para organizar a lógica de negócios. Também compartilhei um helper personalizado que desenvolvi para facilitar a integração entre dry-validation e Grape.
Embora algumas dessas soluções tenham sido desenvolvidas rapidamente para um MVP, elas demonstram como podemos criar APIs mais robustas e manuteníveis com relativamente pouco esforço.
No próximo post vamos falar de Middleware,Policies e Authorization com login usando JWT tokens, com isso o projeto vai ficar mais robusto ainda. Fique ligado!
Você já usou dry-validation ou interactors em seus projetos Rails? Qual foi sua experiência? Deixe um comentário abaixo!
Repositório: https://github.com/rodrigonbarreto/event_reservation_system/tree/mvp_sample_pt2
Referências:
Me chamo Rodrigo Nogueira - Desenvolvedor Ruby on Rails desde 2015
Pós em Engenharia de Software (PUC-RJ) e trabalho com tecnologia desde 2011.
Se você quiser acompanhar esta jornada, por favor, comente e deixe seu like!