Skip to content

Instantly share code, notes, and snippets.

@arsduo
Created August 7, 2012 16:15
Show Gist options
  • Save arsduo/3286857 to your computer and use it in GitHub Desktop.
Save arsduo/3286857 to your computer and use it in GitHub Desktop.
A proposal for a RESTful Batch API
class BatchController < ApplicationController
# Public: a proposal for a batch request endpoint implementation in Rails, inspired
# by Facebook's Batch API (http://developers.facebook.com/docs/reference/api/batch/).
# If this makes sense technically, a Rails engine gem will be forthcoming :)
#
# Batch requests take the form of a series of REST API requests,
# each containing the following arguments:
# url - the API endpoint to hit, formatted exactly as you would for a
# regular REST API request (e.g. leading /, etc.)
# method - what type of request to make -- GET, POST, PUT, etc.
# args - a hash of arguments to the API. This can be used for both GET and
# PUT/POST/PATCH requests.
# headers - a hash of request-specific headers. (The headers sent in the
# request will be included as well, with request-specific headers
# taking precendence.)
# options - a hash of additional batch request options. There are currently
# none supported, but we plan to introduce some for dependency
# management, supressing output, etc. in the future.
#
# The Batch API endpoint itself (which lives at POST /batch) takes the
# following arguments:
#
# ops - an array of operations to perform, specified as described above.
# sequential - execute all operations sequentially, rather than in
# parallel. *THIS PARAMETER IS CURRENTLY REQUIRED AND MUST
# BE SET TO TRUE.* (In the future we'll offer parallel processing by
# default, and hence this parameter must be supplied in order to
# preserve expected behavior.
#
# Other options may be defined in the future.
#
# Users must be logged in to use the Batch API.
#
# The Batch API returns an array of results in the same order the operations are
# specified. Each result contains:
# status - the HTTP status (200, 201, 400, etc.)
# body - the rendered body
# headers - any response headers
# cookies - any cookies set by the request. (These will in the future be
# pulled into the main response to be processed by the client.)
#
# Errors in individual Batch API requests will be returned inline, with the
# same status code and body they would return as individual requests. If the
# Batch API itself returns a non-200 status code, that indicates a global
# problem:
# 403 - if the user isn't logged in
# 422 - if the batch request isn't properly formatted
# 500 - if there's an application error in the Batch API code
#
# ** Examples **
#
# Given the following request:
# {
# ops: [
# {
# method: "post",
# url: "/resource/create",
# args: {title: "bar", data: "foo"}
# },
# {
# method: "get",
# url: "/other_resource/123/connections"
# },
# {
# method: "get",
# url: "/i/gonna/throw/an/error",
# header: { some: "headers" }
# }
# ]
# }
#
# You'd get the following back:
#
# [
# {status: 201, body: "{json:\"data\"}", headers: {}, cookies: {}},
# {status: 200, body: "[{json:\"data\"}, {more:\"data\"}]", headers: {}, cookies: {}},
# {status: 500, body: "{error:\"message\"}", headers: {}, cookies: {}},
# ]
#
# ** Implementation**
#
# For each request, we:
# * attempt to route it as Rails would (identifying controller and action)
# * create a customized request.env hash with the appropriate details
# * instantiate the controller and invoke the action
# * parse and process the result
#
# The overall result is then returned to the client.
#
# **Background**
#
# Batch APIs, though unRESTful, are useful for reducing HTTP overhead
# by combining requests; this is particularly valuable for mobile clients,
# which may generate groups of offline actions and which desire to
# reduce battery consumption while connected by making fewer, better-compressed
# requests.
#
# Generally, such interfaces fall into two categories:
# * a set of limited, specialized instructions, usually to manage resources
# * a general-purpose API that can take any operation the main API can
# handle
#
# We've opted for the second approach, which we believe minimizes code
# duplication and complexity. Rather than have two systems that manage
# resources (or a more complicated one that can handle both batch and
# individual requests), we simply route requests as we always would.
# This approach has several benefits:
# * Less complexity - non-batch endpoints don't need any extra code
# * Complete flexibility - as we add new features or endpoints to the API,
# they become immediately available via the Batch API.
# * More RESTful - as individual operations are simply actions on RESTful
# resources, we preserve an important characteristic of the API.
# As well as general benefits of using the Batch API:
# * Parallelizable - in the future, we could run requests in parallel (if
# our Rails app is running in thread-safe mode), allowing clients to
# specify explicit dependencies between operations (or run all
# sequentially).
# * Reuse of state - user authentication, request stack processing, and
# similar processing only needs to be done once.
# * Better for clients - fewer requests, better compressibility, etc.
# (as described above)
#
# There are two main downsides to our implementation:
# * Rails dependency - we use only public Rails interfaces, but these could
# still change with major updates. (_Resolution:_ with good testing we
# can identify changes and update code as needed.)
# * Reduced ability to optimize cross-request - unlike a specialized API,
# each request will be treated in isolation, and so you couldn't minimize
# DB updates through more complicated SQL logic. (_Resolution:_ none, but
# the main pain point currently is at the HTTP connection layer, so we
# accept this.)
#
# Once the Batch API is more developed, we'll spin it off into a gem, and
# possibly make it easy to create versions for Sinatra or other frameworks,
# if desired.
# Public: the controller action for the Batch API. Takes a POST request.
def batch
ops = params[:ops].map {|o| BatchOp.new(o, request.env)}
render :json => ops.map(&:execute)
end
end
# Internal: an error thrown during a batch operation.
# This has a body class and a cookies accessor and can
# function in place of a regular BatchResponse object.
class BatchError
# Public: create a new BatchError from a Rails error.
def initialize(error)
@message = error.message
@backtrace = error.backtrace
end
# Public: here for compatibility with BatchResponse interface.
attr_reader :cookies
# Public: the error details as a hash, which can be returned
# to clients as JSON. (More thoughtful error handling will
# come in the future.)
def body
unless Rails.env.production?
{
message: @message,
backtrace: @backtrace
}
else
{ message: @message }
end
end
end
# Internal: an individual batch operation.
class BatchOp
attr_accessor :method, :url, :params, :headers
attr_accessor :env, :result
# Public: create a new Batch Operation given the specifications for a batch
# operation (as defined above) and the request environment for the main
# batch request.
def initialize(op, base_env)
@op = op
@method = op[:method]
@url = op[:url]
@params = op[:params]
@headers = op[:headers]
# deep_dup to avoid unwanted changes across requests
@env = base_env.deep_dup
end
# Internal: given a URL and other operation details as specified above,
# identify the appropriate controller to execute the action.
#
# This method will raise an error if the route doesn't exist.
def identify_routing
@path_params = Wunderapi::Application.routes.recognize_path(@url, @op)
@controller = ActionDispatch::Routing::RouteSet::Dispatcher.new.controller(@path_params)
end
# Internal: customize the request environment. This is currently done
# manually and feels clunky and brittle, but is mostly likely fine, though
# there are one or two environment parameters not yet adjusted.
def process_env
path, qs = @url.split("?")
# rails routing
@env["action_dispatch.request.path_parameters"] = @path_param
@env["action_controller.instance"] = @controller
# Headers
headrs = (@headers || {}).inject({}) do |heads, (k, v)|
heads["HTTP_" + k.gsub(/\-/, "_").upcase] = v
end
# preserve original headers unless explicitly overridden
@env.merge!(headrs)
# method
@env["REQUEST_METHOD"] = @method.upcase
# path and query string
@env["REQUEST_URI"] = @env["REQUEST_URI"].gsub(/\/batch.*/, @url)
@env["REQUEST_PATH"] = path
@env["ORIGINAL_FULLPATH"] = @url
@env["PATH_INFO"] = @url
@env["rack.request.query_string"] = qs
@env["QUERY_STRING"] = qs
# parameters
@env["action_dispatch.request.parameters"] = @params
@env["action_dispatch.request.request_parameters"] = @params
@env["rack.request.query_hash"] = @method == "get" ? @params : nil
end
# Public: Execute a batch request, returning a BatchResponse object. If an error
# occurs, it returns the same results as Rails would.
#
# Returns a BatchResponse object
def execute
begin
identify_routing
process_env
action = @controller.action(@path_param[:action])
result = action.call(@env)
BatchResponse.new(result)
rescue => err
puts err.class
puts err.message
puts err.backtrace.join("\n")
error_response(err)
end
end
# Public: create a BatchResponse for an exception thrown during batch
# processing.
def error_response(err)
wrapper = ActionDispatch::ExceptionWrapper.new(@env, err)
BatchResponse.new([
wrapper.status_code,
{},
BatchError.new(err)
])
end
end
# Internal: a response from an internal operation in the Batch API.
# It contains all the details that are needed to describe the call's
# outcome.
class BatchResponse
# Public: the attributes of the HTTP response.
attr_accessor :status, :body, :headers, :cookies
# Public: create a new response representation from a Rack-compatible
# response (e.g. [status, headers, response_object]).
def initialize(response)
@status = response.first
@headers = response[1]
response_object = response[2]
@body = response_object.body
@cookies = response_object.cookies
end
end
@jtarchie
Copy link

jtarchie commented Aug 7, 2012

I feel like there are too many inconsistancies an internal of Rails that are being used here.

How about a Rack adapter? Haven't tried it yet, but you get the idea.

https://gist.github.com/3288847

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment