March 24, 2023; in Ruby on Rails

Creating a Conventional, Stripe-like API With Grape and Ruby on Rails

Grape, from the docs:

Grape is a REST-like API framework for Ruby. It’s designed to run on Rack or complement existing web application frameworks such as Rails and Sinatra by providing a simple DSL to easily develop RESTful APIs. It has built-in support for common conventions, including multiple formats, subdomain/prefix restriction, content negotiation, versioning and much more.

It’s really nice. While it’s not the most performant of frameworks, technology choices are choices that all have their pros and cons. I like Grape because it comes with many of the things I want baked in: easily-generated documentation support, typed-ish parameters, and so much more. The so much more is what I’m going to talk about today.

I consider Stripe’s API documentation to be the gold standard for a REST API. While HackerNews is all about GraphQL, Stripe remains REST! And for most of my projects, I don’t stand to benefit from GraphQL. In this post I’m going to show my usual Grape setup, which produces clean, Stripe-like API resources and documentation while keeping the developer experience mint.

We’re aiming for a response of this:

{
  "data": [
    {
      "id": 1,
      "name": "joshmn",
      "object": "author"
    },
  ],
  "has_more": true,
  "object": "list"
}

Without having to write something like this:

module V1
  class Authors < Grape::API
    namespace :authors do
      params do
        optional :page, type: Integer, desc: "Page number (if using pagination)"
        optional :per_page, type: Integer, desc: "Number of results per page (if using pagination)"
      end
      get do
        pagy, authors = pagy(Author.all, page: params[:page] || 1, items: params[:per_page] || 30)
        array = AuthorBlueprint.render_as_hash(authors)
        object = {}
        object[:has_more] = pagy.next.present?
        object[:type] = "list"
        object[:data] = array
        object.to_json
      end
    end
  end
end

Let’s get started:

I use Blueprinter for my serialization these days. You don’t have to, but I do. Anything will do. I also use Pagy for pagination, and will use it here, too.

First, an app, and some basic housekeeping:

rails new stripeish
cd stripeish
bundle add blueprinter grape pagy 
rails db:create
rails g model Author name 
rails db:migrate

An application-wide Blueprint:

class ApplicationBlueprint < Blueprinter::Base
  def self.object_field
    field :object do |object|
      object.model_name.singular
    end
  end
end

ApplicationBlueprint.object_field exists so we can get the object name of a given resource. You can mix this in however you want.

An Author blueprint:

class AuthorBlueprint < ApplicationBlueprint
  object_field
  field :id
  field :name
end

In app/api/api.rb (redundant, but I’m weird):

module API
  class API < Grape::API
    version 'v1', using: :path
    prefix :api

    format :json

    mount ::V1::Authors
  end
end

A quick Author resource in app/api/v1/authors.rb:

module V1
  class Authors < Grape::API
    namespace :authors do
      get do
        Author.all
      end
    end
  end
end

Mount it:

Rails.application.routes.draw do
  mount ::API::API => '/'
end

And then run your Rails server and you should be able to hit /api/v1/authors and see some stuff. Cool.

Now the fun stuff.

Grape has this option called a Formatter which ultimately handles what you return from a specific API endpoint. We’re going to use it to build some conventions. These conventions will allow us to remove verbosity per endpoint which in turn gives the developer more time to focus on hard problems.

A Formatter object takes two arguments:

  1. A resource — the object returned from the endpoint
  2. The Rack env

And it expects us to return a String (or JSON-ified thing).

We’ll use this object for a few things:

  1. Serialization based on object type
  2. Injection of metadata from pagination

Inferred Serialization

This is pretty straightforward. If we adhere to Rails naming conventions we can infer the serializer:

class CustomJSONFormatter
  DATA_OBJECTS = [ActiveRecord::AssociationRelation, ActiveRecord::Relation, ActiveRecord::Base].freeze 
  EXCLUDED_OBJECTS = [String].freeze

  class << self
    def call(resource, env)
      return resource if EXCLUDED_OBJECTS.include?(resource.class)

      "#{resource.model_name.name}Blueprint".constantize
      blueprint.render_as_hash(resource, options).to_json
    end
  end
end

Fun.

Pagination Injection

This gets a little more interesting because of how Pagy works. A naive way of handling the pagination object would be return a tuple in each resource, where one of them was a Pagy object, and using that object to return the object from our formatter. But that to me seems kind of gross. Instead, what we’ll do is store the pagination data into the Rack env and retrieve it later.

We’ll need to include Pagy::Backend in the Grape API as a helper.

helpers do
  include Pagy::Backend

  def pagy(collection)
    page = params[:page] || 1
    per_page = params[:per_page] || 30
    pagy, items = super(collection, items: per_page, page: page)
    env['api.pagy'] = pagy
    items
  end
end

Note: env is exposed here since it’s part of the request lifecycle.

Then in our formatter, we’ll check if this key exists:

def extract_blueprint_options(_resource, env)
  options = {}

  if pagy = env['api.pagy']
    options[:has_more] = pagy.next.present?
    options[:object] = "list"
  end

  options
end

That looks good.

Because we can paginate, let’s add a helper for pagination params:

module Grape
  module DSL
    module Parameters
      def pagination_params
        optional :page, type: Integer, desc: "Page number (if using pagination)"
        optional :per_page, type: Integer, desc: "Number of results per page (if using pagination)"
      end
    end
  end
end

Now, all together:

module V1
  class Authors < Grape::API
    namespace :authors do
      params do
        pagination_params
      end
      get nil do
        pagy(Author.all)
      end
    end
  end
end

And a get /api/v1/authors?per_page=1&page=1

Returns:

{
  "data": [
    {
      "id": 1,
      "name": "Test",
      "object": "author"
    },
  ],
  "has_more": true,
  "object": "list"
}