DEV Community

Rodrigo Nogueira
Rodrigo Nogueira

Posted on • Edited on

4 1

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

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

Introduction

Hello, devs! 👋

Last year, I embarked on a project with a friend where we decided to create an application using Ruby on Rails for the backend API and React for the frontend. As many of you know, creating a robust and well-documented API can be a challenging task, especially when you want to focus more on development than documentation.

This journey began when I realized I was tired of writing RSpec tests just to generate documentation with Rswag. So we made a decision: we will write tests, but to validate our code as it should be done, not to generate API documentation.

That's when we decided to use Grape. At first, I confess there was some resistance and distrust from my friend. But today, with our stack 100% ready, I can confidently say it was one of the best decisions I convinced him to make. He also trusted and gave 100% support in these types of decisions.

In this series of posts, I'll share our complete journey, from basic configuration to more advanced features. In today's post, I'll show you how to set up an API using Grape with Rails and write effective tests for it.

What is Grape?

Grape is a framework for building REST-like APIs in Ruby. It's designed to work with frameworks like Rails and Sinatra, or even standalone. Some of the features that made us choose Grape include:

  • Clear and expressive syntax for defining endpoints and routes
  • Integrated parameter validation
  • Flexible response formatting
  • Automatic documentation with Swagger
  • Simplified API versioning

Installation and Initial Setup

To get started, we need to add the necessary gems to our project. Add these gems to your Gemfile:

gem 'grape'
gem 'grape-swagger'
gem 'grape-entity'
gem 'grape-swagger-entity'
gem 'rswag-ui'
gem 'rack-cors'
Enter fullscreen mode Exit fullscreen mode

Run bundle install to install the dependencies.

Setting Up the Project

Let's start with a simple model for our events:

class Event < ApplicationRecord
  enum event_type: { business: 'business', birthday: 'birthday' }
end
Enter fullscreen mode Exit fullscreen mode

File Structure

To keep our code organized, we created the following directory structure:

app/
├── api/
│   ├── entities/
│   │   └── event_response.rb
│   └── v1/
│       ├── api_grape.rb
│       └── events.rb
└── models/
    └── event.rb
Enter fullscreen mode Exit fullscreen mode

Route Configuration

In the config/routes.rb file, we mount our API and Swagger documentation:

Rails.application.routes.draw do
  mount Rswag::Ui::Engine => "/api-docs"

  mount V1::ApiGrape => "api", as: :v1_api

  # The code below is commented out, as we're now using Grape instead of Rails' default routes
  # namespace :api do
  #   namespace :v1 do
  #     resources :events
  #   end
  # end
end
Enter fullscreen mode Exit fullscreen mode

Swagger UI Configuration

To improve the API documentation viewing experience, let's configure Rswag UI to point to our Swagger documentation generated by Grape. In config/initializers/rswag_ui.rb:

Rswag::Ui.configure do |c|
  # This is the URL generated by grape-swagger and our configuration file app/api/v1/api_grape.rb
  c.swagger_endpoint "/api/v1/swagger_doc.json", "Events V1 Docs"

  # Add Basic Auth in case your API is private
  # c.basic_auth_enabled = true
  # c.basic_auth_credentials 'username', 'password'
end
Enter fullscreen mode Exit fullscreen mode

It's important to highlight that the endpoint /api/v1/swagger_doc.json generates documentation in OpenAPI 2.0 format. I'm using mount Rswag::Ui::Engine to make the documentation view more pleasant, although I've already started working on a gem to generate OpenAPI 3.0 documentation (a topic for a future post). Even so, this configuration has worked perfectly for the working scenario between me and my friend.

Defining the API

The main API configuration is in the app/api/v1/api_grape.rb file:

# frozen_string_literal: true

class V1::ApiGrape < Grape::API
  version "v1", using: :path
  format :json
  default_format :json

  # Mount your endpoints here
  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
Enter fullscreen mode Exit fullscreen mode

Implementing the Endpoints

In the app/api/v1/events.rb file, we implement the complete CRUD for our events:

# frozen_string_literal: true

module V1
  class Events < Grape::API
    helpers do
      def event_params
        ActionController::Parameters.new(params).permit(
          :title,
          :description,
          :event_type,
          :number_of_people,
          :special_requests
        )
      end

      def find_event
        @event = Event.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        error!({ error: "Event not found" }, 404)
      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" }
        ],
        tags: ["events"]
      }
      params do
        requires :id, type: Integer, desc: "Event ID"
      end
      get ":id" do
        find_event
        present @event, with: ::Entities::EventResponse
      end

      desc "Create a new event", {
        success: ::Entities::EventResponse,
        failure: [
          { code: 401, message: "Unauthorized" },
          { code: 422, message: "Unprocessable Entity" }
        ],
        tags: ["events"]
      }
      params do
        requires :title, type: String, desc: "Event title"
        requires :event_type, type: String, values: %w[business birthday], desc: "Event type"
        optional :description, type: String, desc: "Event description"
        optional :number_of_people, type: Integer, desc: "Number of attendees"
        optional :special_requests, type: String, desc: "Special requests"
      end
      post do
        @event = Event.new(event_params)
        if @event.save
          present @event, with: ::Entities::EventResponse
        else
          error!({ errors: @event.errors.full_messages }, 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" }
        ],
        tags: ["events"]
      }
      params do
        requires :id, type: Integer, desc: "Event ID"
        optional :title, type: String, desc: "Event title"
        optional :event_type, type: String, values: %w[business birthday], desc: "Event type"
        optional :description, type: String, desc: "Event description"
        optional :number_of_people, type: Integer, desc: "Number of attendees"
        optional :special_requests, type: String, desc: "Special requests"
      end
      put ":id" do
        find_event
        if @event.update(event_params)
          present @event, with: ::Entities::EventResponse
        else
          error!({ errors: @event.errors.full_messages }, 422)
        end
      end

      desc "Delete an event", {
        failure: [
          { code: 401, message: "Unauthorized" },
          { code: 404, message: "Not Found" }
        ],
        tags: ["events"]
      }
      params do
        requires :id, type: Integer, desc: "Event ID"
      end
      delete ":id" do
        find_event
        if @event.destroy
          { success: true, message: "Event successfully deleted" }
        else
          error!({ errors: @event.errors.full_messages }, 422)
        end
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Defining Entities for Data Representation

To format our API responses, we use Grape entities in app/api/entities/event_response.rb:

# frozen_string_literal: true

module Entities
  class EventResponse < Grape::Entity
    expose :id, documentation: { type: "Integer", desc: "Event ID" }
    expose :title, documentation: { type: "String", desc: "Event Title" }
    expose :description, documentation: { type: "String", desc: "Event Description" }
    expose :event_type, documentation: { type: "String", desc: "Event Type" }
    expose :number_of_people, documentation: { type: "Integer", desc: "Number of People" }
    expose :special_requests, documentation: { type: "String", desc: "Special Requests" }
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing the API with RSpec

One of the great advantages of Grape is that we can write direct and efficient tests for our API without needing to generate documentation through them. Let's see how this works:

Setting Up Tests

First, add the necessary gems to your Gemfile:

group :development, :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
end
Enter fullscreen mode Exit fullscreen mode

Run bundle install and configure RSpec with rails generate rspec:install.

Creating Factory for Tests

In spec/factories/events.rb:

# frozen_string_literal: true

FactoryBot.define do
  factory :event do
    title { "Event Title" }
    description { "Event Description" }
    event_type { "business" }
    number_of_people { 5 }
    special_requests { "Event Special Requests" }

    trait :business do
      event_type { "business" }
    end

    trait :birthday do
      event_type { "birthday" }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing the Endpoints

In spec/api/v1/events_spec.rb:

# frozen_string_literal: true

require "rails_helper"

RSpec.describe V1::Events, type: :request do
  let(:base_url) { "/api/v1/events" }
  let(:event_type) { "business" }
  let(:title) { "Meeting with clients" }
  let(:description) { "Quarterly planning meeting" }
  let(:number_of_people) { 10 }
  let(:special_requests) { "Need projector and coffee" }

  let(:valid_event_params) do
    {
      title: title,
      description: description,
      event_type: event_type,
      number_of_people: number_of_people,
      special_requests: special_requests
    }
  end

  let(:invalid_event_params) do
    {
      title: "",
      event_type: "invalid_type"
    }
  end

  describe "GET /api/v1/events" do
    context "when there are events" do
      before do
        create(:event, title: "First event", event_type: "business")
        create(:event, title: "Second event", event_type: "birthday")
      end

      it "returns all events" do
        get base_url
        expect(response).to have_http_status(:success)
        json_response = JSON.parse(response.body)
        expect(json_response.size).to eq(2)
        expect(json_response.first["title"]).to eq("First event")
        expect(json_response.second["title"]).to eq("Second event")
      end
    end

    context "when there are no events" do
      it "returns an empty array" do
        get base_url

        expect(response).to have_http_status(:success)
        json_response = JSON.parse(response.body)

        expect(json_response).to be_empty
      end
    end
  end

  describe "GET /api/v1/events/:id" do
    context "when the event exists" do
      let(:event) { create(:event, valid_event_params) }

      it "returns the event" do
        get "#{base_url}/#{event.id}"

        expect(response).to have_http_status(:success)
        json_response = JSON.parse(response.body)

        expect(json_response["id"]).to eq(event.id)
        expect(json_response["title"]).to eq(event.title)
        expect(json_response["description"]).to eq(event.description)
        expect(json_response["event_type"]).to eq(event.event_type)
        expect(json_response["number_of_people"]).to eq(event.number_of_people)
        expect(json_response["special_requests"]).to eq(event.special_requests)
      end
    end

    context "when the event does not exist" do
      it "returns a 404 error" do
        get "#{base_url}/999"

        expect(response).to have_http_status(:not_found)
        json_response = JSON.parse(response.body)

        expect(json_response["error"]).to eq("Event not found")
      end
    end
  end

  describe "POST /api/v1/events" do
    context "with valid parameters" do
      it "creates a new event" do
        expect do
          post base_url, params: valid_event_params
        end.to change(Event, :count).by(1)

        expect(response).to have_http_status(:created)
        json_response = JSON.parse(response.body)

        expect(json_response["title"]).to eq(title)
        expect(json_response["description"]).to eq(description)
        expect(json_response["event_type"]).to eq(event_type)
        expect(json_response["number_of_people"]).to eq(number_of_people)
        expect(json_response["special_requests"]).to eq(special_requests)
      end
    end

    context "with invalid parameters" do
      it "does not create an event and returns errors" do
        expect do
          post base_url, params: invalid_event_params
        end.not_to change(Event, :count)

        expect(response).to have_http_status(:bad_request)
        json_response = JSON.parse(response.body)
        expect(json_response).to have_key("error")
        expect(json_response["error"]).to eq("event_type does not have a valid value")
      end
    end
  end

  describe "PUT /api/v1/events/:id" do
    let!(:event) { create(:event, valid_event_params) }
    let(:new_title) { "Updated Event Title" }
    let(:update_params) { { title: new_title } }

    context "when the event exists" do
      it "updates the event" do
        put "#{base_url}/#{event.id}", params: update_params

        expect(response).to have_http_status(:success)
        json_response = JSON.parse(response.body)

        expect(json_response["id"]).to eq(event.id)
        expect(json_response["title"]).to eq(new_title)
        expect(event.reload.title).to eq(new_title)
      end
    end

    context "when the event does not exist" do
      it "returns a 404 error" do
        put "#{base_url}/999", params: update_params

        expect(response).to have_http_status(:not_found)
        json_response = JSON.parse(response.body)

        expect(json_response["error"]).to eq("Event not found")
      end
    end

    context "with invalid parameters" do
      it "does not update the event and returns errors" do
        put "#{base_url}/#{event.id}", params: invalid_event_params

        expect(response).to have_http_status(:bad_request)
        JSON.parse(response.body)
      end
    end
  end

  describe "DELETE /api/v1/events/:id" do
    let!(:event) { create(:event, valid_event_params) }

    context "when the event exists" do
      it "deletes the event" do
        expect do
          delete "#{base_url}/#{event.id}"
        end.to change(Event, :count).by(-1)

        expect(response).to have_http_status(:success)
        json_response = JSON.parse(response.body)

        expect(json_response["success"]).to be true
        expect(json_response["message"]).to eq("Event successfully deleted")
      end
    end

    context "when the event does not exist" do
      it "returns a 404 error" do
        delete "#{base_url}/999"

        expect(response).to have_http_status(:not_found)
        json_response = JSON.parse(response.body)

        expect(json_response["error"]).to eq("Event not found")
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Important Details About Testing

During the development of the tests, we discovered some peculiarities of Grape that are worth sharing:

  1. Two-Layer Validation: Grape has two layers of validation:

    • Parameter Validation: Happens before the endpoint code is executed and returns status 400 (Bad Request)
    • Model Validation: Happens within the endpoint code and returns status 422 (Unprocessable Entity)
  2. Behavior with Invalid Parameters: When we use values: %w[business birthday] in the parameter definition, Grape automatically rejects invalid values with a 400 error before even executing the endpoint code. Our tests reflect this expected behavior by expecting status 400 when we send an invalid event_type.

  3. Specific Error Messages: Grape provides specific error messages, such as "event_type does not have a valid value", which are useful both for debugging and for properly informing API users.

Running the tests:

bundle exec rspec spec/api/v1/events_spec.rb

# Result:
# Finished in 0.04411 seconds (files took 1.16 seconds to load)
# 11 examples, 0 failures
Enter fullscreen mode Exit fullscreen mode

Benefits of Using Grape

After implementing our API with Grape, we noticed several benefits:

  1. Cleaner and more organized code: Grape's structure helps us keep the code organized and easy to understand.

  2. Automatic documentation: Grape automatically generates Swagger documentation without the need for specific tests for this purpose.

  3. Robust parameter validation: Integrated parameter validation happens before code execution, avoiding unnecessary processing.

  4. More direct tests: Tests focus on verifying the actual behavior of the API, not on generating documentation.

  5. Clear error messages: Grape provides specific and well-formatted error messages.

  6. Now, when I specify the response format:
    present @event, with: ::Entities::EventResponse
    I just need to define the expected response type in the endpoint:
    success: ::Entities::EventResponse

Many times we change the serializer and fix the test where we changed the data, but we forget about other points where the same serializer is used. If the test is not 100% well covered, our API starts to become inconsistent in Swagger documentation or with a high maintenance cost.

How to Test Manually

After setting up your API, you can test it using tools like Postman, curl, or the automatically generated Swagger interface:

  1. Start your Rails server: rails s
  2. Access the Swagger documentation: http://localhost:3000/api-docs
  3. Try out the available endpoints

Next Steps

In this post, we saw how to set up a basic API with Grape and Rails and how to test it efficiently. In the next posts in the series, I'll cover more advanced topics, such as:

  • Using the contracts gem for validation
  • Organizing code with the Interactor gem and controlling actions with organizers and interactors
  • Creating custom helpers
  • Adding middlewares for error handling
  • Authentication and authorization with Pundit
  • Pagination of resources
  • And much more!

Conclusion

Using Grape with Rails to create APIs has been an extremely positive experience. The combination of an expressive API with direct and efficient tests has allowed us to focus on what really matters: building a robust and well-documented API without the overhead of tests aimed solely at documentation.

If you're tired of writing tests just to generate documentation and want a more direct and efficient solution for your APIs, I definitely recommend trying Grape.

See you in the next post, where we'll delve into more advanced Grape features!


Have you used Grape in your Rails projects? What were your experiences? Leave your comments below!


Example repository: https://github.com/rodrigonbarreto/event_reservation_system/tree/mvp_sample

References:


Rodrigo Nogueira • Ruby on Rails Developer since 2015
Postgraduate in Software Engineering (PUC-RJ) & passionate about technology since 2011.

If you want to follow this journey, please comment and leave your like!

Top comments (0)