DEV Community

Rodrigo Nogueira
Rodrigo Nogueira

Posted on

Creating APIs with Grape and Rails: A Complete Journey - Part 2

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:

  1. dry-validation - For robust parameter validation
  2. 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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Image description

Image description

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The organizer does two important things:

  1. Defines the order of execution for the interactors using organize
  2. 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
Enter fullscreen mode Exit fullscreen mode

Notice how our API is cleaner and more organized:

  1. We use ::Helpers::ContractToParams.generate_params(EventsCreateContract) to automatically generate parameter documentation.
  2. We validate parameters with dry-validation contracts.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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

  1. Separation of concerns: Validation is completely separate from the API and models.
  2. More powerful validations: We can express complex business rules clearly.
  3. Reusability: Contracts can be used in different contexts, not just in the API.
  4. Automatic documentation: With our custom helper, documentation is generated automatically.

Using Interactors

  1. Modular code: Each interactor has a single responsibility.
  2. Easy to test: Interactors can be tested in isolation.
  3. Transaction control: The organizer takes care of wrapping everything in a transaction.
  4. 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)