DEV Community

Rodrigo Nogueira for Vídeos de Ti

Posted on • Edited on

6 1 1 1

Criando APIs com Grape e Rails: Uma Jornada Completa - Parte 1

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

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

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

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

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

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

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

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

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

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

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

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

Detalhes Importantes sobre os Testes

Durante o desenvolvimento dos testes, descobrimos algumas particularidades do Grape que vale a pena compartilhar:

  1. 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)
  2. 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 um event_type inválido.

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

Benefícios do Uso do Grape

Após implementar nossa API com Grape, percebemos diversos benefícios:

  1. Código mais limpo e organizado: A estrutura do Grape nos ajuda a manter o código organizado e fácil de entender.

  2. Documentação automática: Grape gera automaticamente documentação Swagger sem a necessidade de testes específicos para isso.

  3. 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.

  4. Testes mais diretos: Os testes se concentram em verificar o comportamento real da API, não em gerar documentação.

  5. Mensagens de erro claras: O Grape fornece mensagens de erro específicas e bem formatadas.

  6. 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:

  1. Inicie seu servidor Rails: rails s
  2. Acesse a documentação Swagger: http://localhost:3000/api-docs
  3. 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!

Image of Quadratic

Python + AI + Spreadsheet

Chat with your data and get insights in seconds with the all-in-one spreadsheet that connects to your data, supports code natively, and has built-in AI.

Try Quadratic free

Top comments (0)

👋 Kindness is contagious

DEV works best when you're signed in—unlocking a more customized experience with features like dark mode and personalized reading settings!

Okay