Grape On Rails

posted 10 years ago

Applications always end up on the client, and the transition for a Rails app can be painful. Grape makes it fun.

# Gemfile

gem 'grape'
gem 'grape-entity'

And install.

$ bundle install

Setup

Create a new app/api folder to hold the api. It can all go in one file to begin, but eventually you might want to separate them out into v1, v2, etc. The api must specify json format, otherwise it doesn't know to call to_json on objects returned by the endpoint.

# app/api/api.rb

class API < Grape::API
  format :json
  default_format :json

  module ErrorFormatter
    def self.call(message, backtrace, options, env)
      API.logger.error(backtrace.join("\n"))
      { status: 500, message: message }.to_json
    end
  end

  rescue_from :all
  error_formatter :json, ErrorFormatter
  default_error_formatter :json
end

The custom error formatter is needed to log the backtrace. Otherwise there will just be the request logged, which makes it impossible to fix the problem.

method=GET host=www.domain.com connect=1 service=168 status=500 bytes=372

Add the api folder to the autoload paths so that the constant API can be found and loaded on invocation (so it just works).

# config/application.rb

  config.autoload_paths << root.join('app/api')

The last step is to mount the api. You can do it with a subdomain or path.

# config/routes.rb

  mount API => '/api'

  # or

  constraints(subdomain: 'api') do
    mount API => '/'
  end

You'll also likely want to include the application helper.

# app/api/api.rb

  helpers ApplicationHelper

RSpec Configuration

You're a ruby developer so you love testing. Let's set that up and test a ping endpoint.

# app/api/api.rb

  get 'ping' do
    { ping: 'pong' }
  end

We need to manually include the module to allow rack request http verbs.

# spec/helper.rb

module Helpers
  def json
    MultiJson.load(response.body)
  end
end

RSpec.configure do |config|
  config.include RSpec::Rails::RequestExampleGroup,
    type: :request, file_path: /spec\/api/
  config.include Helpers
end

Alright. Now we can make requests in a test.

# spec/api/api_spec.rb

describe API do
  describe 'GET /ping' do
    before { get '/api/ping' }
    it 'should return pong' do
      expect(json['ping']).to eq('pong')
    end
  end
end

The test suite should pass.

$ alias 'rspec'='RAILS_ENV=test bundle exec rspec'
$ rspec

Base Entity

Check out grape-entity, a vast improvement over JBuilder for resource rendering. It's useful to use a base entity and extend it for each resource representation.

# app/api/api.rb

class API < Grape::API
  class Entity < Grape::Entity
  end

  class UserEntity < Entity
  end
end

Functionality included in the base entity extends to each resource entity necessarily through inheritance, so give it some power. Let's start with some format_with helpers.

class Entity < Grape::Entity
  [ :iso8601, :to_s, :count ].each do |sym|
    format_with(sym, &sym)
  end
end

This allows you to use these formatters in every other entity, for example for timestamps and posts count.

class UserEntity < Entity
  expose :id, format_with: :to_s
  expose :posts, as: :posts_count, format_with: :count
  with_options(format_with: :iso8601) do
    expose :created_at, :updated_at
  end
end

Formatters are pretty useful. For example you can do truncation just like in erb views.

class Entity
  include ActionView::Helpers::TextHelper

  format_with(:truncate) do |string|
    truncate(string.gsub(/\s+/, ' '), length: 140, separator: ' ')
  end
end

Also, for url helpers such as resource_url(@resource), we need to manually deal with the url options host.

class Entity
  include Rails.application.routes.url_helpers

  def default_url_options
    Rails.application.routes.default_url_options
  end
end

Each environment needs to be manually updated with the correct host as well.

# config/environments/production.rb

  routes.default_url_options = { host: 'www.domain.com' }

# config/environments/development.rb

  routes.default_url_options = { host: 'localhost', port: 3000 }

# config/environments/test.rb

  routes.default_url_options = { host: 'local.test' }

Tweet if you need help @aj0strow. Thanks for reading.