Creating a conventional, Stripe-like API with Grape and Ruby on Rails - Josh Brody Creating a conventional, Stripe-like API with Grape and Ruby on Rails | Josh Brody
Back

Creating a conventional, Stripe-like API with Grape and Ruby on Rails

Here’s the thing about API endpoints: you end up writing the same boilerplate over and over. Pagination metadata. Object type annotations. Wrapping collections in data arrays. Every endpoint looks identical except for the one line that matters—the actual query.

I got tired of it. So I built a convention system with Grape that handles all of it automatically. Now my endpoint code looks like this:

# thing.rb
get do
  pagy(Author.all)
end

And the response looks like this:

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

No manual JSON construction. No remembering to add has_more. No repetitive blueprint rendering calls. The framework handles it.

Why Grape (and why not just Rails controllers)

Before we get into the implementation—yes, you can do all of this with plain Rails controllers. You can also write your own HTTP server in C. The question is whether it’s worth your time.

Grape gives you things out of the box that Rails controllers don’t:

  • Typed parameters with automatic validation. Define params { requires :id, type: Integer } and Grape rejects bad input before your code runs. In Rails, you’re writing params[:id].to_i and hoping for the best;
  • Built-in API versioning. Path-based, header-based, whatever you want. Rails has none of this;
  • Documentation generation. Grape integrates with grape-swagger to generate OpenAPI specs from your parameter definitions. Your docs stay in sync with your code because they are your code;
  • Custom formatters that let you intercept every response and transform it before it goes out the door

Is Grape slower than raw Rails? Yes—maybe 10-20% on micro-benchmarks. Does that matter for your API that spends 95% of its time waiting on the database? Almost certainly not.

If you’re building something where that overhead matters, you probably shouldn’t be using Ruby at all.

The setup

New app, basic dependencies:

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

I use Blueprinter for serialization. You don’t have to—any serializer works—but I do. Pagy handles pagination.

The serialization layer

First, an application-wide blueprint that adds the object field automatically:

# app/blueprints/application_blueprint.rb
class ApplicationBlueprint < Blueprinter::Base
  def self.object_field
    field :object do |object|
      object.model_name.singular
    end
  end
end

Then a blueprint for each model:

# app/blueprints/author_blueprint.rb
class AuthorBlueprint < ApplicationBlueprint
  object_field
  field :id
  field :name
end

Calling object_field in each blueprint is intentional. You’ll want some resources that don’t include the object type—nested associations, for example. Making it opt-in keeps you in control.

The API structure

The main API class:

# app/api/api.rb
module API
  class API < Grape::API
    version 'v1', using: :path
    prefix :api
    format :json

    mount ::V1::Authors
  end
end

Mount it in your routes:

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

A basic resource:

# app/api/v1/authors.rb
module V1
  class Authors < Grape::API
    namespace :authors do
      get do
        Author.all
      end
    end
  end
end

Hit /api/v1/authors and you’ll get… a raw array. Not what we want. Time for the interesting part.

The custom formatter

Grape’s formatter is where the magic happens. It intercepts every response and transforms it before sending. We’ll use it to:

  1. Automatically serialize ActiveRecord objects using their corresponding blueprints
  2. Inject pagination metadata when present
  3. Wrap collections in the Stripe-style data / has_more / object structure
# app/api/custom_json_formatter.rb
class CustomJSONFormatter
  DATA_OBJECTS = [
    ActiveRecord::AssociationRelation,
    ActiveRecord::Relation,
    ActiveRecord::Base
  ].freeze

  EXCLUDED_OBJECTS = [String, Hash].freeze

  class << self
    def call(resource, env)
      return resource.to_json if excluded?(resource)
      return resource.to_json unless serializable?(resource)

      blueprint = infer_blueprint(resource)
      options = extract_options(env)

      if collection?(resource)
        build_list_response(blueprint, resource, options, env)
      else
        blueprint.render(resource)
      end
    end

    private

    def excluded?(resource)
      EXCLUDED_OBJECTS.any? { |klass| resource.is_a?(klass) }
    end

    def serializable?(resource)
      DATA_OBJECTS.any? { |klass| resource.is_a?(klass) }
    end

    def collection?(resource)
      resource.is_a?(ActiveRecord::Relation) ||
        resource.is_a?(ActiveRecord::AssociationRelation)
    end

    def infer_blueprint(resource)
      model_class = collection?(resource) ? resource.klass : resource.class
      "#{model_class.name}Blueprint".constantize
    end

    def extract_options(env)
      options = {}
      if (pagy = env['api.pagy'])
        options[:has_more] = pagy.next.present?
      end
      options
    end

    def build_list_response(blueprint, resource, options, env)
      data = blueprint.render_as_hash(resource)

      response = {
        object: 'list',
        data: data
      }

      if (pagy = env['api.pagy'])
        response[:has_more] = pagy.next.present?
      end

      response.to_json
    end
  end
end

Wire it into your API:

# app/api/api.rb
module API
  class API < Grape::API
    version 'v1', using: :path
    prefix :api
    format :json
    formatter :json, CustomJSONFormatter

    mount ::V1::Authors
  end
end

Now returning Author.all from an endpoint automatically serializes it using AuthorBlueprint, wraps it in a list structure, and includes the object type. No extra code required.

Pagination that doesn’t pollute your endpoints

Returning a tuple from every endpoint—[pagy_object, records]—is gross. The endpoint should return what it’s querying, not bookkeeping objects.

Instead, we’ll store pagination state in the Rack environment and let the formatter retrieve it:

# app/api/api.rb (inside the class)
helpers do
  include Pagy::Backend

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

The env hash is the Rack environment—available throughout the request lifecycle. We stash the Pagy object there, and the formatter picks it up later.

Now calling pagy(Author.all) paginates the collection and ensures the response includes has_more. The endpoint code doesn’t change.

Reusable pagination parameters

Define them once in a Grape DSL extension:

# config/initializers/grape_pagination.rb
module Grape
  module DSL
    module Parameters
      def pagination_params
        optional :page, type: Integer, desc: 'Page number (defaults to 1)'
        optional :per_page, type: Integer, desc: 'Results per page (defaults to 30, max 100)'
      end
    end
  end
end

Use them in any endpoint:

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

Grape generates parameter documentation automatically. Your API docs now include pagination params with descriptions.

The complete picture

Here’s everything together. The endpoint:

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

      route_param :id do
        get do
          Author.find(params[:id])
        end
      end
    end
  end
end

GET /api/v1/authors?per_page=2&page=1 returns:

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

GET /api/v1/authors/1 returns:

{
  "id": 1,
  "name": "Alice",
  "object": "author"
}

No serialization calls. No response building. No pagination metadata juggling. The convention handles it.

When this breaks down

This approach has tradeoffs. Be aware of them:

Non-standard responses. Sometimes you need to return something that doesn’t fit the convention—aggregated data, custom structures, responses from external services. The formatter checks for Hash and String and passes them through unchanged, but you’ll need to build those responses manually.

Nested associations. If you want Author to include their Books, you need to handle that in the blueprint. The formatter won’t automatically serialize nested relations—you’ll define that relationship in AuthorBlueprint using Blueprinter’s association DSL.

Performance at scale. Inferring the blueprint class via constantize on every request adds overhead. It’s negligible for typical loads, but if you’re serving thousands of requests per second, you might want to cache the lookup or make the blueprint explicit.

Debugging opacity. When something goes wrong in the formatter, the stack trace isn’t always obvious. Add logging liberally while developing.

What you get

A system where adding a new resource means:

  1. Create the model
  2. Create a blueprint with object_field and the fields you want
  3. Create a resource class with the query

Everything else—serialization, pagination, response structure, documentation—is handled. Your endpoint code stays focused on the actual business logic.

That’s the whole point of conventions. You make decisions once, encode them in infrastructure, and stop thinking about them. The formatter is ~50 lines of code. It saves you from writing the same 10 lines in every endpoint, forever.

The full code is straightforward to extend. Add total_count to list responses. Add rate limiting headers. Add request timing. Whatever you need—you add it once, and every endpoint gets it.

That’s the leverage.

Stay in the loop

Occasional essays on design, tools, and the craft of building things. No spam, unsubscribe anytime.

Ambient weather

The background of this site reflects the current weather and time of day in Saint Paul. The orbs shift in color and behavior based on what's happening outside my window.

Learn more about how this works