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'
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
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
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
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
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
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
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
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
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
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
Important Details About Testing
During the development of the tests, we discovered some peculiarities of Grape that are worth sharing:
-
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)
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 invalidevent_type
.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
Benefits of Using Grape
After implementing our API with Grape, we noticed several benefits:
Cleaner and more organized code: Grape's structure helps us keep the code organized and easy to understand.
Automatic documentation: Grape automatically generates Swagger documentation without the need for specific tests for this purpose.
Robust parameter validation: Integrated parameter validation happens before code execution, avoiding unnecessary processing.
More direct tests: Tests focus on verifying the actual behavior of the API, not on generating documentation.
Clear error messages: Grape provides specific and well-formatted error messages.
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:
- Start your Rails server:
rails s
- Access the Swagger documentation:
http://localhost:3000/api-docs
- 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)