Search code examples
ruby-on-railsruby-grapegrape-api

How can I have Grape return error messages in CSV format?


I have a Rails app and I have implemented api using Grape gem. Now, I created a custom error formatter (CSVFormatter) to return error response in CSV format.

And, also I have this in my application's v2.rb file:

error_formatter :csv, Api::Base::Errors::CSVFormatter

When I hit a url like this:

http://example.com/api/v2/datasets/CODE/data.csv?&trim_start=06/01/99&trim_end=2014-05/28&sort_order=desc

It shows the error in the console like this which is good and means that my custom error formatter is working properly:

Error 
trim_start is invalid 
trim_end is invalid

But, I just need to download this error message in a csv file. After looking at Grape's documentation, I found a way of setting Content-type and I tried this:

      rack = Rack::Response.new(as_csv , 422, { "Content-type" => "text/csv" }).finish
      rack[2].body[0]

But, this is not working as I expected.

EDIT:

Looks like there is no clean way of doing it using grape without forcefully overriding the status code according to the answer of Simon. But, one may not wish to do that as it may result other issues in the application like if some other program tries to read the data from the api and gets the incorrect response or so even without knowing why.


Solution

  • You're looking for the Content-Disposition header. Include it in your response like this:

    Content-Disposition: attachment; filename=error.csv
    

    And the Web browser will treat the response body as a file to be downloaded (to "error.csv", in this example).

    However, modifying your code to do this is complicated by two things:

    • From the Grape source code it's apparent there's no way to set response headers from within an error formatter, so you'll need to add a custom exception handler that formats the response body and sets the response headers appropriately for each output format you plan to support.

    • According to my experimentation, browsers will ignore the Content-Disposition header if the HTTP status code indicates an error (e.g. anything in the 400 or 500 range), so the status code will also need to be overridden when the user requests a CSV file.

    Try adding this to your API class:

    # Handle all exceptions with an error response appropriate to the requested
    # output format
    rescue_from :all do |e|
      # Edit this hash to override the HTTP response status for specific output
      # formats
      FORMAT_SPECIFIC_STATUS = {
        :csv => 200
      }
    
      # Edit this hash to add custom headers specific to each output format
      FORMAT_SPECIFIC_HEADERS = {
        :csv => {
          'Content-Disposition' => 'attachment; filename=error.csv'
        }
      }
    
      # Get the output format requested by the user
      format = env['api.format']
    
      # Set the HTTP status appropriately for the requested output format and
      # the error type
      status = FORMAT_SPECIFIC_STATUS[format] ||
                 (e.respond_to? :status) && e.status ||
                 500
    
      # Set the HTTP headers appropriately for the requested format
      headers = {
        'Content-Type' => options[:content_types][format] || 'text/plain'
      }.merge(FORMAT_SPECIFIC_HEADERS[format] || { })
    
      # Format the message body using the appropriate error formatter
      error_formatter =
        options[:error_formatters][format] || options[:default_error_formatter]
      body = error_formatter.call(e.message, nil, options, env)
    
      # Return the error response to the client in the correct format
      # with the correct HTTP headers for that format
      Rack::Response.new(body, status, headers).finish
    end
    

    Now if you configure your API class to handle two different formats (I've picked CSV and plain-text here for simplicity), like this:

    module Errors
      module CSVErrorFormatter
        def self.call(message, backtrace, options, env)
          as_csv = "CSV formatter:" + "\n"
          message.split(",").each do |msg|
            as_csv += msg + "\n"
          end
    
          # Note this method simply returns the response body
          as_csv
        end
      end
    
      module TextErrorFormatter
        def self.call(message, backtrace, options, env)
          as_txt = "Text formatter:" + "\n"
          message.split(",").each do |msg|
            as_txt += msg + "\n"
          end
    
          as_txt
        end
      end
    end
    
    content_type :csv, 'text/csv'
    content_type :txt, 'text/plain'
    
    error_formatter :csv, Api::Base::Errors::CSVErrorFormatter
    error_formatter :txt, Api::Base::Errors::TextErrorFormatter
    

    You should find your API always returns an error response suitable for the requested format, and triggers the browser to download the response only when CSV format is requested. Naturally this can be extended to support as many formats as you like, by explicitly declaring content types and error formatters.

    Note there's one case in which this code doesn't automatically do the right thing, and that's when an error response is invoked directly using error!. In that case you'll have to supply the correct body and headers as part of the call itself. I'll leave extracting the relevant parts of the above code into reusable methods as an exercise for the reader.