Building High Performance Ruby REST APIs with Rage

This article is written by Roman Samoilov, Tech Lead at SoftServe, and creator of Rage. All opinions expressed are his own.

Rage is a Ruby web framework designed for building fast, modern, and developer-friendly APIs.

With the goal of improving and modernizing the Ruby ecosystem, it tries to strike a balance between new and existing solutions - Rage provides the same syntax as Rails while focusing on asynchronous I/O, performance, and effortless generation of OpenAPI documentation.

In this article, we will use Rage to build a shared Todo list. Users will be able to add items to this list, mark them as completed, and group the items by category:

todo list

Let's walk through the process of building this application, and then we'll summarize the key takeaways.

Creating a project#

First, we will need to create a new project. Let’s install the gem and set up a project with an SQLite database:

$ gem install rage-rb
$ rage new todo-list -d sqlite3
$ cd todo-list
$ bundle install

Next, let’s generate our models. The following commands will create both model and migration files:

$ rage g model Item
$ rage g model Group

ℹ️ You can see the full list of commands using `rage --tasks`.

We will need to update the models to set up the associations:

# app/models/group.rb
class Group < ApplicationRecord
  has_many :items
end

# app/models/item.rb
class Item < ApplicationRecord
  belongs_to :group
end

And update the migrations to define the tables:

# db/migrate/20250401110130_create_groups.rb
class CreateTodoGroups < ActiveRecord::Migration[8.0]
  def change
    create_table :groups do |t|
      t.string :name, null: false
      t.timestamps
    end
  end
end

# db/migrate/20250401110130_create_items.rb
class CreateItems < ActiveRecord::Migration[8.0]
  def change
    create_table :items do |t|
      t.string :content, null: false
      t.boolean :is_completed, null: false, default: false
      t.references :group, index: true, null: false
      t.timestamps
    end
  end
end

Finally, let’s seed the database with the first record and create the database:

# db/seeds.rb
Item.create!(
  group: Group.new(name: "New Year's resolutions"),
  content: "Build fast APIs with Rage",
  created_at: Time.now.beginning_of_year
)
$ rage db:create && rage db:migrate && rage db:seed

List API#

Next, let’s proceed to creating the list API. We will update our routes:

# config/routes.rb
Rage.routes.draw do
  scope path: "api/v1" do
    resources :items, only: %i[index]
  end
end

and create the controller class:

# app/controllers/items_controller.rb
class ItemsController < ApplicationController
  def index
  end
end

Since our Todo items are attached to groups, and we’d like to have structured JSON responses where each item is nested within a group, we will build our serializers with Alba. Let’s update the Gemfile:

# Gemfile
gem "alba"

We will also set the inflector to enable Alba to automatically determine the correct serializer classes for the associations:

# config/initializers/alba_inflector.rb
Alba.inflector = :active_support

Next, let’s create the actual serializers:

# app/resources/application_resource.rb
class ApplicationResource
  include Alba::Resource

  root_key :data, :data
  transform_keys :lower_camel
end

# app/resources/group_resource.rb
class GroupResource < ApplicationResource
  attributes :id, :name
  has_many :items
end

# app/resources/item_resource.rb
class ItemResource < ApplicationResource
  attributes :content, is_completed: :Boolean
end

Here, we create three classes:

  • `ApplicationResource` will be used as a base class for all serializers in the application - it includes the `Alba::Resource` module and instructs Alba to add the root key to the payload. Additionally, we enable the key transformation to convert attribute names to camel case.
  • `GroupResource` defines the `id` and `name` attributes and renders the `items` association. Thanks to setting the inflector, we don’t need to manually specify the serializer class for the association.
  • `ItemResource` renders the `content` attribute and the `is_completed` flag, for which we enable automatic type conversion.

And now, it’s time to put it all together in the controller. We will fetch the records from the database and render them using the serializer:

class ItemsController < ApplicationController
  def index
    groups = Group.includes(:items).all
    render json: GroupResource.new(groups)
  end
end

Start the server using `rage s` and navigate to http://localhost:3000/api/v1/items to see the rendered list.

OpenAPI documentation#

We’ve built our first endpoint, but how will the client application understand how to use it and what data to expect? With Rage’s OpenAPI solution, building the documentation comes down to two simple steps.

Step 0#

Mount the `Rage::OpenAPI` component at the URL of your choice:

# config/routes.rb
mount Rage::OpenAPI.application, at: "/publicapi"

Step 1#

Document your endpoint:

class ItemsController < ApplicationController
  # Get all Todo items grouped by their parent items.
  # @response Array<GroupResource>
  def index
    groups = Group.includes(:items)
    render json: GroupResource.new(groups)
  end
end

Here, we use YARD-style tags to specify what the endpoint does and what payload it responds with using the Alba serializer. Without any additional annotations or code changes, Rage will use the serializer to construct the response payload. If you now visit http://localhost:3000/publicapi, you will see the documentation with our list endpoint, its summary, and the response payload built based on the Alba serializer:

API docs

ℹ️ Use the OpenAPI Explorer tool to experiment with `Rage::OpenAPI` without creating a new project.

Pagination#

To allow clients to paginate through long lists of items using infinite scroll, let’s also add simple offset-based pagination. To do that, we will need to update our documentation to accept two optional parameters and update the Active Record query:

class ItemsController < ApplicationController
  # Get all Todo items grouped by their parent items.
  # @param limit? Number of items per page
  # @param offset? The offset of the first item to return
  # @response Array<GroupResource>
  def index
    limit  = params[:limit]  || 10
    offset = params[:offset] || 0
    groups = Group.includes(:items).limit(limit).offset(offset)

    render json: GroupResource.new(groups)
  end
end

Create API#

Let’s now add the create API. It will accept the details of a Todo item and respond with the serialized record:

# config/routes.rb
Rage.routes.draw do
  scope path: "api/v1" do
    resources :items, only: %i[index create]
  end
end

# app/controllers/items_controller.rb
class ItemsController < ApplicationController
  # Create a new Todo item.
  # @request { item: { content: String, group_id: Integer } }
  # @response ItemResource
  def create
    item = Item.create!(item_params)
    render json: ItemResource.new(item)
  end

  private

  def item_params
    params.fetch(:item).slice(:content, :group_id)
  end
end

In this code, we:

  • Add the new action to the `resources` call in `config/routes.rb`.
  • Define a new controller action and document it using OpenAPI tags. We use an inline YAML syntax to specify the request payload.
  • Define the `item_params` method to filter the parameters required to create an item.
  • Create a new item and render it back using the item serializer.

If you now visit http://localhost:3000/publicapi, you will see the updated documentation:

POST API docs

Real-time updates#

We’ve built an API that allows us to view and create Todo items. But since our Todo list is shared, we’d like users to be notified of each other’s actions. For example, if Alice creates a new Todo item, it should automatically appear in Bob’s application without him having to refresh the page. Meet `Rage::Cable`!

Mount a new application#

Let’s update the routes to mount the new component:

# config/routes.rb
mount Rage::Cable.application, at: "/cable"

And create a new channel:

# app/channels/items_channel.rb
class ItemsChannel < Rage::Cable::Channel
  def subscribed
    stream_from "todo-items"
  end
end

Here, we attach all subscribed clients to the `todo-items` stream. Clients will be able to use the actioncable package to subscribe to this stream and listen for updates.

Finally, let’s update our API to send notifications every time a new Todo item is created:

class ItemsController < ApplicationController
  # Create a new Todo item.
  # @request { item: { content: String, group_id: Integer } }
  # @response ItemResource
  def create
    item = Item.create!(item_params)
    render json: ItemResource.new(item)
    Rage::Cable.broadcast("todo-items", { action: "new-item", data: item })
  end
end

In this code, we’ve added the `Rage::Cable.broadcast` call to push the updates to the clients. The first argument is the name of the stream to which the clients are subscribed. The second argument is the payload, which can essentially be any Ruby object - in this example, we specify that this message represents a new item added to the list and send the actual item under the `data` key.

Why Choose Rage?#

Hopefully, at this point you are thinking something along the lines of “Wait, this is boring! It’s almost like Rails!”.

Yes! Rage is designed to feel familiar, making it easy for developers to transition and build robust applications quickly. It attempts to work exactly the way you would expect it to. Still, while Rage’s syntax is well-understood, behind the scenes it implements several major improvements compared to existing frameworks.

Rage brings seamless asynchronous I/O and performance to Ruby, making Ruby a more versatile technology. Rage delivers impressive performance on par with existing Python and Elixir solutions, and the support for asynchronous I/O means you can now use Ruby to build services that would otherwise be built with Node.js or Go.

Benchmarks: Rage vs Ruby on Rails#

Hello World

class BenchmarksController < ApplicationController
  def index
    render json: { hello: "world" }
  end
end

Hello world benchmark vs Ruby on Rails

Waiting on I/O

require "net/http"

class BenchmarksController < ApplicationController
  def index
    Net::HTTP.get(URI("<endpoint-that-responds-in-one-second>"))
    head :ok
  end
end

Waiting on I/O benchmark vs Ruby on Rails

Using ActiveRecord

class BenchmarksController < ApplicationController
  def show
    render json: World.find(rand(1..10_000))
  end
end

Using ActiveRecord benchmark vs Ruby on Rails

Wrapping Up#

Ready to give Rage a try? The best way to understand Rage’s capabilities is to dive in and experiment with building your own APIs. Check out Rage’s API docs and Fiber helpers for a deeper understanding of its features.

ℹ️ Rage welcomes contributors! If you're interested in getting involved, check out the repository for a list of good first issues.

Zuplo has many features that will enhance your API and reduce the maintenance burden on your development team. Give it a try!

To learn more, check out Zuplo's excellent documentation.

Questions? Let's chatOPEN DISCORD
0members online

Designed for Developers, Made for the Edge