DEV Community

Rodrigo Nogueira
Rodrigo Nogueira

Posted on

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

Introduction

Hello, devs! 👋

Welcome to the third part of our series on Grape and Rails! In the previous posts, we explored the basic API configuration and implemented CRUD operations for our events. Today, we'll take a step further and implement authentication for our API.

Security is a crucial aspect of any API, and implementing a robust authentication system is essential to protect your resources. In this post, we'll explore how to implement authentication with JWT (JSON Web Tokens) and how to protect our routes.

API doc

Setting Up Authentication with JWT

For our example, we'll use JWT for authentication. If you're not familiar with JWT, it's a standard for creating access tokens that can be validated without the need to query a database. JWT tokens contain information about the user and expire after a certain period.

First, let's add the necessary gems to our project:

gem 'devise'  # For user authentication
gem 'jwt'     # For working with JWT tokens
Enter fullscreen mode Exit fullscreen mode

Run bundle install to install the dependencies.

1. Creating the JWT Service

First, we need to create a service to handle the encoding and decoding of JWT tokens. In app/services/jwt/json_web_token.rb:

# frozen_string_literal: true

module Jwt
  class JsonWebToken
    # SECRET = Rails.application.credentials.secret_key_base
    SECRET = "secret-key" # use some code in secrets or something more secure
    ENCRYPTION = "HS256"

    def self.encode(payload, exp = 120.hours.from_now)
      payload[:exp] = exp.to_i
      JWT.encode(payload, SECRET)
    rescue JWT::EncodeError => e
      Rails.logger.error("JWT Encode Error: #{e.message}")
    end

    def self.decode(token)
      body = JWT.decode(token, SECRET)[0]
      ActiveSupport::HashWithIndifferentAccess.new(body)
    rescue JWT::ExpiredSignature, JWT::DecodeError => e
      Rails.logger.error("JWT Encode Error: #{e.message}")
      nil
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Note: In a production environment, you should store the secret key in a secure location, such as Rails.application.credentials.secret_key_base or an environment variable.

2. Creating Authentication Helpers

Next, let's create helpers to handle authentication in our endpoints. In app/api/helpers/auth_helpers.rb:

# frozen_string_literal: true

module Helpers
  module AuthHelpers
    extend Grape::API::Helpers

    # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
    def authenticate_request!
      # This example is simplified; in the real project, we only use headers["Authorization"]
      token = headers["Authorization"]&.split&.last || headers["authorization"]&.split&.last
      error!({ error: "Unauthorized", success: false }, 401) unless token

      # Decode the JWT token
      @jwt_result = Jwt::JsonWebToken.decode(token)
      error!({ error: "Invalid Token", success: false }, 401) unless @jwt_result

      # Fetch the current user (or generate an error if not found)
      current_user!
    rescue StandardError => e
      error!({ error: "Authentication Error: #{e.message}" }, 401)
    end
    # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity

    # This method fetches the current user and generates an error if not found
    def current_user!
      @current_user ||= User.find_by(id: @jwt_result["id"])
      error!({ error: "Invalid Token", success: false }, 401) unless @current_user
      @current_user
    end

    # This method allows using current_user anywhere
    def current_user
      @current_user
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

3. Implementing the Login Endpoint

Now, let's create an endpoint to authenticate users and generate JWT tokens. In app/api/v1/login.rb:

# frozen_string_literal: true

module V1
  class Login < Grape::API
    resource :login do
      desc "Login ", {
        success: Entities::Users::LoginResponse,
        failure: [{ code: 401, message: "Unauthorized" }],
        params: ::Helpers::ContractToParams.generate_params(LoginContract)
      }
      post do
        contract = LoginContract.new
        result = contract.call(params)
        error!({ errors: result.errors.to_h }, 422) if result.failure?

        @user = User.find_by(email: params[:email])
        if @user&.valid_password?(params[:password])
          token = ::Jwt::JsonWebToken.encode({ id: @user.id })
          status 200
          present({ user: @user, token: token }, with: Entities::Users::LoginResponse)
        else
          error!({ error: "Unauthorized" }, 401)
        end
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Note: This example uses Devise's valid_password?, which is a standard method for verifying passwords.

4. Defining Entities for Login Response

To format the login endpoint responses, let's create some entities:

In app/api/entities/users/user_response.rb:

# frozen_string_literal: true

module Entities
  module Users
    class UserResponse < Grape::Entity
      expose :id, documentation: { type: "Integer", desc: "User id" }
      expose :email, documentation: { type: "String", desc: "User email" }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

In app/api/entities/users/login_response.rb:

# frozen_string_literal: true

module Entities
  module Users
    class LoginResponse < Grape::Entity
      expose :user, using: Entities::Users::UserResponse,
                   documentation: { type: "Array", desc: "List of users" }
      expose :token, documentation: { type: "String", desc: "JWT token" }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

5. Updating the Grape API

Now, let's update our app/api/v1/api_grape.rb file to include the authentication helpers and the new login endpoint:

# frozen_string_literal: true

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

  use CustomErrorMiddleware

  helpers ::Helpers::AuthHelpers

  mount V1::Events
  mount V1::Login

  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

6. Adding Test Data

To facilitate testing, we can create a default user using seeds:

# db/seeds.rb

User.find_or_create_by!(email: "[email protected]") do |user|
  user.password = "123456"
  user.password_confirmation = "123456"
end
Enter fullscreen mode Exit fullscreen mode

Run rails db:seed to create the user.

Protecting Endpoints

With our authentication setup in place, we can now protect our endpoints. Let's update our events controller to require authentication:

module V1
  class Events < Grape::API
    before { authenticate_request! }

    # The rest of the code remains the same...
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, all endpoints in the events resource will require a valid JWT token for access.

Implementing Middleware for Error Handling

To further improve our API, let's implement a custom middleware to handle common errors. In app/middleware/custom_error_middleware.rb:

# frozen_string_literal: true

class CustomErrorMiddleware < Grape::Middleware::Base
  def call(env)
    @app.call(env) # Pass the request forward
  rescue Grape::Exceptions::MethodNotAllowed => e
    handle_method_not_allowed(e, env)
  rescue ActiveRecord::RecordNotFound => e
    handle_record_not_found(e)
  rescue StandardError => e
    handle_standard_error(e)
  end

  private

  def handle_record_not_found(error)
    # Could use, for example: Sentry.capture_exception(error, extra: { message: error.message })
    error_response(message: "Couldn't find #{error.model || 'record'} with id: #{error.id}", status: 404)
  end

  def handle_method_not_allowed(error, env)
    request_method = env["REQUEST_METHOD"]
    path = env["PATH_INFO"]

    Rails.logger.error "Method not allowed: #{request_method} for path #{path}"

    allowed_methods = if error.respond_to?(:headers) && error.headers["Allow"]
                        "Allowed methods: #{error.headers['Allow']}"
                      else
                        "Please use the appropriate HTTP methods for this endpoint"
                      end

    error_message = "The #{request_method} method is not allowed for this resource. #{allowed_methods}. please check your parameters"

    unless Rails.env.development?
      # Sentry.capture_exception(error, extra: {
      #                            request_method: request_method,
      #                            path: path,
      #                            message: error_message
      #                          })
    end

    error_response(message: error_message, status: 405)
  end

  def handle_standard_error(error)
    Rails.logger.error(error.message)
    Rails.logger.error(error.backtrace.join("\n"))
    # Sentry.capture_exception(error, extra: { message: error.message }) unless Rails.env.development?
    error_response(message: "Something went wrong", status: 500)
  end

  def error_response(message:, status:)
    throw :error, message: { error: message }, status: status
  end
end
Enter fullscreen mode Exit fullscreen mode

This middleware captures common exceptions and transforms them into appropriate API responses. With it implemented, we can simplify our endpoints:

get ":id" do
  @event = Event.find(params[:id])
  present @event, with: ::Entities::EventResponse
end
Enter fullscreen mode Exit fullscreen mode

The middleware will automatically capture the ActiveRecord::RecordNotFound exception and return an appropriate error response.

Testing Authentication

Now that we've implemented authentication, let's add some tests to ensure everything works correctly:

# spec/api/v1/login_spec.rb

require 'rails_helper'

RSpec.describe V1::Login, type: :request do
  let(:base_url) { "/api/v1/login" }
  let(:email) { "[email protected]" }
  let(:password) { "password123" }

  before do
    @user = create(:user, email: email, password: password)
  end

  describe "POST /api/v1/login" do
    context "with valid credentials" do
      it "returns a JWT token" do
        post base_url, params: { email: email, password: password }

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

        expect(json_response["user"]["id"]).to eq(@user.id)
        expect(json_response["user"]["email"]).to eq(@user.email)
        expect(json_response["token"]).not_to be_nil
      end
    end

    context "with invalid credentials" do
      it "returns an error" do
        post base_url, params: { email: email, password: "wrong_password" }

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

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

Updated Swagger Documentation

With the addition of authentication and the login endpoint, our Swagger documentation will now show:

  1. A new /login endpoint for authentication
  2. Security information for all protected endpoints
  3. An "Authorize" button in the Swagger interface to enter the JWT token

Login

Add Login token

Conclusion

In this third part of our series, we implemented a complete authentication system for our Grape API:

  1. Set up JWT to generate and verify tokens
  2. Created helpers for authentication
  3. Implemented a login endpoint
  4. Protected our endpoints with authentication
  5. Added middleware for error handling
  6. Updated our Swagger documentation

Authentication is a crucial component for any API, and Grape makes it relatively simple to implement. By combining JWT with Grape's helpers, we achieved a robust authentication system that protects our resources and provides a pleasant developer experience.

In the next post, we'll explore more advanced Grape features such as pagination, sorting, and filtering to make our API even more powerful and flexible.


Did you like this post? Leave your comments below and don't forget to check out the previous parts of this series!

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

References:


My name is Rodrigo Nogueira Barreto. I've been working with Ruby on Rails since 2015.

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

Top comments (0)