Ruby Grape JSON-over-HTTP API, custom JSON representation
Asked Answered
Z

5

10

I have a small prototype subclass of Grape::API as a rack service, and am using Grape::Entity to present my application's internal objects.

I like the Grape::Entity DSL, but am having trouble finding out how I should go beyond the default JSON representation, which is too lightweight for our purposes. I have been asked to produce output in "jsend or similar" format: http://labs.omniti.com/labs/jsend

I am not at all sure what nature of change is most in keeping with the Grape framework (I'd like a path-of-least-resistance here). Should I create a custom Grape formatter (I have no idea how to do this), new rack middleware (I have done this in order to log API ins/outs via SysLog - but formatting seems bad as I'd need to parse the body back from JSON to add container level), or change away from Grape::Entity to e.g. RABL?

Example code ("app.rb")

require "grape"
require "grape-entity"

class Thing
  def initialize llama_name
    @llama_name = llama_name
  end
  attr_reader :llama_name
end

class ThingPresenter < Grape::Entity
  expose :llama_name
end

class MainService < Grape::API
  prefix      'api'
  version     'v2'
  format      :json
  rescue_from :all

  resource :thing do
    get do
      thing = Thing.new 'Henry'
      present thing, :with => ThingPresenter
    end
  end
end

Rackup file ("config.ru")

require File.join(File.dirname(__FILE__), "app")
run MainService

I start it up:

rackup -p 8090

And call it:

curl http://127.0.0.1:8090/api/v2/thing
{"llama_name":"Henry"}

What I'd like to see:

curl http://127.0.0.1:8090/api/v2/thing
{"status":"success","data":{"llama_name":"Henry"}}

Obviously I could just do something like

  resource :thing do
    get do
      thing = Thing.new 'Henry'
      { :status => "success", :data => present( thing, :with => ThingPresenter ) }
    end
  end

in every route - but that doesn't seem very DRY. I'm looking for something cleaner, and less open to cut&paste errors when this API becomes larger and maintained by the whole team


Weirdly, when I tried { :status => "success", :data => present( thing, :with => ThingPresenter ) } using grape 0.3.2, I could not get it to work. The API returned just the value from present - there is more going on here than I initially thought.

Zn answered 19/3, 2013 at 11:22 Comment(0)
Z
15

This is what I ended up with, through a combination of reading the Grape documentation, Googling and reading some of the pull requests on github. Basically, after declaring :json format (to get all the other default goodies that come with it), I over-ride the output formatters with new ones that add jsend's wrapper layer. This turns out much cleaner to code than trying to wrap Grape's #present helper (which doesn't cover errors well), or a rack middleware solution (which requires de-serialising and re-serialising JSON, plus takes lots of extra code to cover errors).

require "grape"
require "grape-entity"
require "json"

module JSendSuccessFormatter
  def self.call object, env
    { :status => 'success', :data => object }.to_json
  end
end

module JSendErrorFormatter
  def self.call message, backtrace, options, env
    # This uses convention that a error! with a Hash param is a jsend "fail", otherwise we present an "error"
    if message.is_a?(Hash)
      { :status => 'fail', :data => message }.to_json
    else
      { :status => 'error', :message => message }.to_json
    end
  end
end

class Thing
  def initialize llama_name
    @llama_name = llama_name
  end
  attr_reader :llama_name
end

class ThingPresenter < Grape::Entity
  expose :llama_name
end

class MainService < Grape::API
  prefix      'api'
  version     'v2'
  format      :json
  rescue_from :all

  formatter :json, JSendSuccessFormatter
  error_formatter :json, JSendErrorFormatter

  resource :thing do
    get do
      thing = Thing.new 'Henry'
      present thing, :with => ThingPresenter
    end
  end

  resource :borked do
    get do
      error! "You broke it! Yes, you!", 403
    end
  end
end
Zn answered 19/3, 2013 at 14:55 Comment(2)
if you wanted to extract the formatters into a separate file, where would they go, and how would you use them?Organelle
@dtmunir - Grape doesn't have default or expected structures (like e.g. Rails has), so files with helper modules in can go anywhere provided you require or require_relative them before you try to use them in the server code. I tend to have a single require at the top of the app, and that in turn is just a list of requires to all the things I define for the Grape app. When I did this for real I only needed a couple of custom helpers, so I just put them in a file helpers.rb - YMMV.Zn
C
2

I believe this accomplishes what your goal is while using grape

require "grape"
require "grape-entity"

class Thing
  def initialize llama_name
    @llama_name = llama_name
  end
  attr_reader :llama_name
end

class ThingPresenter < Grape::Entity
  expose :llama_name
end

class MainService < Grape::API
  prefix      'api'
  version     'v2'
  format      :json
  rescue_from :all

  resource :thing do
    get do
      thing = Thing.new 'Henry'
      present :status, 'success'
      present :data, thing, :with => ThingPresenter
    end
  end
end
Camelopardalis answered 9/5, 2014 at 20:24 Comment(2)
It does - not sure when that three-part structure was available to present (whether I missed it, or it has been added in the last year). However, I would need to add an extra present :status, 'success' to each handler. More properly it is part of the general format, not something I'd expect to manually code multiple times.Zn
@NeilSlater I agree, but since it is REST your "standard" response can be 100% different than someone else's. I'm sure there is some method of template which can accomplish this goal without the repetition. Hopefully, this helps someone though! have a good one.Camelopardalis
C
1

I'm using @Neil-Slater's solution with one additional modification I thought others may find useful.

With just a rescue_from :all the result for common 404 errors are returned as 403 Forbidden. Also, the status is 'error' when it should be 'fail'. To address these issues I added a rescue handler for RecordNotFound:

rescue_from ActiveRecord::RecordNotFound do |e|
  Rails.logger.info e.message
  error = JSendErrorFormatter.call({message: e.message}, e.backtrace, {}, nil)
  Rack::Response.new(error, 404,
                     { "Content-type" => "text/error" }).finish
end

note - I couldn't figure out the proper way to access the rack env so you can see I am passing it in as a nil value (which is okay since the error handler doesn't use the value).

I suppose you could further extend this approach to further refine response code handling. For me, the tricky part was figuring that I needed a Rack::Response object that I could pass the formatted error message into.

Charmian answered 19/3, 2013 at 11:22 Comment(1)
Additional note: This change messed up my grape-swagger documentation because the new json formatter was overwriting the format of the json-swagger data that that gem uses internally. That's not cool to tromp on other APIs - I think it was fair that swagger broke under that condition. So instead, even though it meant a lot of cut and paste, I put the json formatter directive at the top of each of my individual APIs rather than in ApplicationAPI. That got my sweet swagger docs working again and kept the "core" APIs using the jsend packaging.Charmian
C
1

You could use a middleware layer for that. Grape has a Middleware::Base module that you can use for this purpose. My not so super beautiful implementation:

class StatusAdder < Grape::Middleware::Base

  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, response = @app.call
    response_hash = JSON.parse response.body.first
    body = { :status => "success", :data => response_hash } if status == 200

    response_string = body.to_json
    headers['Content-Length'] = response_string.length.to_s
    [status, headers, [response_string]]
  end
end

And in the MainService class, you'd add a line: use ::StatusAdder

Carpetbag answered 19/3, 2013 at 14:1 Comment(6)
Thanks. So far this is the only option I can see working, but my problem with it is that the middleware approach de-serialises and re-serialises the JSON. Surely there has to be a way of wrapping the returned data before it is serialised?Zn
Well, that would depend on your requirement on whether the :status part should change depending on the actual HTTP status code of the response. For example, if the response code is "404", do you still want to send a JSON string that has "status":"success" part in the response?Carpetbag
I do want status to vary, and there will be a strong correspondence between HTTP status, and the value associated with "status" in the JSON hash. There are two other status values, "fail" and "error" with specific uses in jsend format. "fail" tends to be used on input validation errors, and includes indication of which parameter failed.Zn
Okay, in that case your answer would be a better idea than mine. But if it has to include the HTTP failure errors (like 200, 404 etc.) then middleware path is the way to take. Subtle difference :)Carpetbag
There's the rub - error conditions according to jsend should have a :code key with a numeric code, and if HTTP statuses are being used, the codes should be the same. Which is +1 for middleware - although I'd happily feed in whatever I'd raised using grape's error! helper, wrapping up the helper methods as in my answer is looking more and more hacky to me . . . if only because future developers familiar with Grape would not be familiar with how I modified itZn
@NeilSlater It used to be that you could insert the middleware before the formatter, which is precisely what I did and that led me to viewing this question after upgrading. ThanksNeuter
C
1

As of today's date, I believe the correct way to do this with Grape is:

    rescue_from Grape::Exceptions::ValidationErrors do |e|
        response = 
        {
            'status' => 'fail',
            'data' => {
                'status' => e.status,
                'message' => e.message,
                'errors' => e.errors 
            }
        }
        Rack::Response.new(response.to_json, e.status)
    end
Cithara answered 1/10, 2013 at 2:9 Comment(1)
Thanks, I might be able to use that as a replacement for the error formatter (or simply to extract proper status codes into the structure). Note that Grape's error! method does not raise an error - it uses throw - so it is not covered by this.Zn

© 2022 - 2024 — McMap. All rights reserved.