Introdução
Olá, devs! 👋
No ano passado, embarquei em um projeto com um amigo onde decidimos criar uma aplicação usando Ruby on Rails para o backend API e React para o frontend. Como muitos de vocês sabem, criar uma API robusta e bem documentada pode ser uma tarefa desafiadora, especialmente quando você quer focar mais no desenvolvimento do que na documentação.
Essa jornada começou quando percebi que estava cansado de escrever testes RSpec apenas para gerar documentação com o Rswag. Então tomamos uma decisão: vamos sim escrever testes, mas para validar nosso código como deve ser feito, não para gerar documentação da API.
Foi aí que decidimos usar o Grape. No início, confesso que havia uma certa resistência e desconfiança do meu amigo. Mas hoje, com nossa stack 100% pronta, posso dizer com confiança que foi uma das melhores decisões que o convenci a tomar. Ele também confiou e deu 100% de suporte nesses tipos de decisões.
Nesta série de posts, vou compartilhar nossa jornada completa, desde a configuração básica até recursos mais avançados. No post de hoje, vou mostrar como configurar uma API usando Grape com Rails e escrever testes eficazes para ela.
O que é Grape?
Grape é um framework para construir APIs REST-like em Ruby. Ele foi projetado para funcionar com frameworks como Rails e Sinatra, ou mesmo de forma standalone. Algumas das características que nos fizeram escolher o Grape incluem:
- Sintaxe clara e expressiva para definir endpoints e rotas
- Validação de parâmetros integrada
- Formatação de resposta flexível
- Documentação automática com Swagger
- Versionamento de API simplificado
Instalação e Configuração Inicial
Para começar, precisamos adicionar as gems necessárias ao nosso projeto. Adicione essas gems ao seu Gemfile
:
gem 'grape'
gem 'grape-swagger'
gem 'grape-entity'
gem 'grape-swagger-entity'
gem 'rswag-ui'
gem 'rack-cors'
Execute bundle install
para instalar as dependências.
Configurando o Projeto
Vamos começar com um modelo simples para nossos eventos:
class Event < ApplicationRecord
enum event_type: { business: 'business', birthday: 'birthday' }
end
Estrutura de Arquivos
Para manter nosso código organizado, criamos a seguinte estrutura de diretórios:
app/
├── api/
│ ├── entities/
│ │ └── event_response.rb
│ └── v1/
│ ├── api_grape.rb
│ └── events.rb
└── models/
└── event.rb
Configuração de Rotas
No arquivo config/routes.rb
, montamos nossa API e a documentação Swagger:
Rails.application.routes.draw do
mount Rswag::Ui::Engine => "/api-docs"
mount V1::ApiGrape => "api", as: :v1_api
# O código abaixo está comentado, pois agora estamos usando Grape em vez das rotas padrão do Rails
# namespace :api do
# namespace :v1 do
# resources :events
# end
# end
end
Configuração do Swagger UI
Para melhorar a experiência de visualização da documentação da API, vamos configurar o Rswag UI para apontar para nossa documentação Swagger gerada pelo Grape. Em config/initializers/rswag_ui.rb
:
Rswag::Ui.configure do |c|
# Esta é a URL gerada pelo grape-swagger e nosso arquivo de configuração 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
É importante destacar que o endpoint /api/v1/swagger_doc.json
gera a documentação no formato OpenAPI 2.0. Estou usando o mount Rswag::Ui::Engine
para deixar a visualização da documentação mais agradável, embora eu já tenha começado a trabalhar em uma gem para gerar documentação OpenAPI 3.0 (assunto para um post futuro). Mesmo assim, esta configuração tem funcionado perfeitamente para o cenário de trabalho entre mim e meu amigo.
Definindo a API
A configuração principal da API fica no arquivo app/api/v1/api_grape.rb
:
# frozen_string_literal: true
class V1::ApiGrape < Grape::API
version "v1", using: :path
format :json
default_format :json
# Monte seus endpoints aqui
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 para gerenciamento de eventos",
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
Implementando os Endpoints
No arquivo app/api/v1/events.rb
, implementamos o CRUD completo para nossos eventos:
# 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
Definindo Entidades para Representação de Dados
Para formatar nossas respostas de API, usamos entidades do Grape em 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
Testando a API com RSpec
Uma das grandes vantagens do Grape é que podemos escrever testes diretos e eficientes para nossa API sem precisar gerar documentação através deles. Vamos ver como isso funciona:
Configurando os Testes
Primeiro, adicione as gems necessárias no seu Gemfile
:
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
end
Execute bundle install
e configure o RSpec com rails generate rspec:install
.
Criando Factory para os Testes
Em 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
Testando os Endpoints
Em 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
Detalhes Importantes sobre os Testes
Durante o desenvolvimento dos testes, descobrimos algumas particularidades do Grape que vale a pena compartilhar:
-
Validação em Duas Camadas: O Grape tem duas camadas de validação:
- Validação de Parâmetros: Acontece antes do código do endpoint ser executado e retorna status 400 (Bad Request)
- Validação do Modelo: Acontece dentro do código do endpoint e retorna status 422 (Unprocessable Entity)
Comportamento com Parâmetros Inválidos: Quando usamos
values: %w[business birthday]
na definição dos parâmetros, o Grape rejeita valores inválidos automaticamente com um erro 400 antes mesmo de executar o código do endpoint. Nossos testes refletem esse comportamento esperando o status 400 quando enviamos umevent_type
inválido.Mensagens de Erro Específicas: O Grape fornece mensagens de erro específicas, como
"event_type does not have a valid value"
, que são úteis tanto para debugging quanto para informar adequadamente os usuários da API.
Executando os testes:
bundle exec rspec spec/api/v1/events_spec.rb
# Resultado:
# Finished in 0.04411 seconds (files took 1.16 seconds to load)
# 11 examples, 0 failures
Benefícios do Uso do Grape
Após implementar nossa API com Grape, percebemos diversos benefícios:
Código mais limpo e organizado: A estrutura do Grape nos ajuda a manter o código organizado e fácil de entender.
Documentação automática: Grape gera automaticamente documentação Swagger sem a necessidade de testes específicos para isso.
Validação de parâmetros robusta: A validação integrada de parâmetros acontece antes da execução do código, evitando processamento desnecessário.
Testes mais diretos: Os testes se concentram em verificar o comportamento real da API, não em gerar documentação.
Mensagens de erro claras: O Grape fornece mensagens de erro específicas e bem formatadas.
Agora, quando eu especifico o formato do response:
present @event, with: ::Entities::EventResponse
basta definir no endpoint o tipo de resposta esperado:
success: ::Entities::EventResponse
Muitas vezes alteramos o serializer e corrigimos o teste onde mudamos os dados, mas esquecemos de outros pontos onde o mesmo serializer é usado. Se o teste não for 100% bem coberto, nossa API começa a ficar inconsistente na documentação Swagger ou com um alto custo de manutenção.
Como Testar Manualmente
Após configurar sua API, você pode testá-la usando ferramentas como Postman, curl ou a interface Swagger gerada automaticamente:
- Inicie seu servidor Rails:
rails s
- Acesse a documentação Swagger:
http://localhost:3000/api-docs
- Experimente os endpoints disponíveis
Próximos Passos
Neste post, vimos como configurar uma API básica com Grape e Rails e como testá-la eficientemente. Nos próximos posts da série, vou abordar tópicos mais avançados, como:
- Uso da gem contracts para validação
- Organizando o código com a gem Interactor e controlando ações com organizers e interactors
- Criação de helpers customizados
- Adição de middlewares para tratamento de erros
- Autenticação e autorização com Pundit
- Paginação de recursos
- E muito mais!
Conclusão
Usar Grape com Rails para criar APIs tem sido uma experiência extremamente positiva. A combinação de uma API expressiva com testes diretos e eficientes nos permitiu focar no que realmente importa: construir uma API robusta e bem documentada sem o overhead de testes voltados apenas para documentação.
Se você está cansado de escrever testes apenas para gerar documentação e quer uma solução mais direta e eficiente para suas APIs, definitivamente recomendo experimentar o Grape.
Nos vemos no próximo post, onde vamos aprofundar em recursos mais avançados do Grape!
Você já usou Grape em seus projetos Rails? Quais foram suas experiências? Deixe seus comentários abaixo!
Repositório do exemplo: https://github.com/rodrigonbarreto/event_reservation_system/tree/mvp_sample
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
Caso queiram acompanhar essa jornada, por favor comentem e deixem seu like!
Top comments (0)