Created
August 7, 2012 16:15
-
-
Save arsduo/3286857 to your computer and use it in GitHub Desktop.
A proposal for a RESTful Batch API
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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