https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3uqykfgpk5b2114e0vyd.png

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:

  1. dry-validation - Para validação robusta de parâmetros
  2. 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

Image description

Image description

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:

  1. Define a ordem de execução dos interactors usando organize
  2. 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:

  1. Usamos ::Helpers::ContractToParams.generate_params(EventsCreateContract) para gerar automaticamente a documentação de parâmetros.
  2. Validamos os parâmetros com os contratos do dry-validation.
  3. 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

  1. Separação de responsabilidades: A validação está completamente separada da API e dos modelos.
  2. Validações mais poderosas: Podemos expressar regras de negócios complexas de maneira clara.
  3. Reutilização: Os contratos podem ser usados em diferentes contextos, não apenas na API.
  4. Documentação automática: Com nosso helper personalizado, a documentação é gerada automaticamente.

Usando Interactors

  1. Código modular: Cada interactor tem uma única responsabilidade.
  2. Fácil de testar: Interactors podem ser testados isoladamente.
  3. Controle transacional: O organizador cuida de envolver tudo em uma transação.
  4. 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!