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:
- A
resource
— the object returned from the endpoint - The Rack env
And it expects us to return a String (or JSON-ified thing).
We’ll use this object for a few things:
- Serialization based on object type
- 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"
}