super action middleware
As a proof of concept I created an super action that allows to process more than one action at once.
Probably most of you will ask a question ‘why’ if there is an posibility to use nested resources. Yes you can, but what if there are more different actions that do not have common root?
Code bellow is only proof of concept, it was tested only using integration tests, if you ever try it more please let me know.
First Step is to create middleware scaffold ‘lib/super_action.rb’:
class SuperAction include Rack::Utils def initialize(app) @app = app end def call(env) @app.call(env) end end
Next we have to connect it, we can not do it as advised in environment file, we also do not want to change the whole stack, the way to go is to hook into application loading process soewhere after it initilaizes all automatic middlewares. Add the following code (module) into ‘config/environment.rb’:
... require File.join(File.dirname(__FILE__), 'boot') module Rails class Initializer alias :old_load_application_classes :load_application_classes def load_application_classes configuration.middleware.delete ActiveRecord::QueryCache configuration.middleware.delete ActiveRecord::ConnectionAdapters::ConnectionManagement configuration.middleware.insert_before ActionController::ParamsParser, 'SuperAction' configuration.middleware.insert_before 'SuperAction', ActiveRecord::ConnectionAdapters::ConnectionManagement configuration.middleware.insert_before 'SuperAction', ActiveRecord::QueryCache old_load_application_classes end end end Rails::Initializer.run do |config| ...
We can not do the middleware changes directly in initializer using config.middleware because then it raises exception NameError on ‘ActiveRecord::QueryCache’ – it is loaded in the initialization stack after environment. So this is the current stack looks now for me:
~/projects/super_action> rake middleware use Rack::Lock use ActionController::Failsafe use ActionController::Session::CookieStore, # use ActiveRecord::ConnectionAdapters::ConnectionManagement use ActiveRecord::QueryCache use SuperAction use ActionController::ParamsParser use Rack::MethodOverride use Rack::Head use ActionController::StringCoercion run ActionController::Dispatcher.new
Important here was to be before ActionController::ParamsParser, because it is responsible for providing correct params to action. Putting ConnectionManagement and QueryCache before super action should be considered as an optimization.
When we have correct order of middlewares now the super action functionality, back to ‘lib/super_action.rb’:
class SuperAction include Rack::Utils def initialize(app) @app = app end def call(env) uri = env['REQUEST_URI'] || env['PATH_INFO'] wrapper = env['rack.input'] body = wrapper.read wrapper.rewind unless body.blank? params, format = if uri == '/super_action.xml' [Hash.from_xml(body), :xml] end end first_response = nil if params response_array = params['requests'].map do |req| req_env = env.clone req_env['PATH_INFO'] = req['url'] req_env['REQUEST_URI'] = req['url'] req_env['REQUEST_METHOD'] = req['method'] req_env['CONTENT_TYPE'] = req['content_type'] req_env['rack.input'] = Rack::Lint::InputWrapper.new StringIO.new( req['body'] || '' ) status, headers, req_response = @app.call(req_env) first_response ||= req_response { :url => req['url'], :method => req['method'], :status => status, :body => req_response.body, :id => req['id'] } end response_body = case format when :xml then xml = Builder::XmlMarkup.new xml.responses 'type' => 'array' do response_array.each do |resp| xml.response do xml.url resp[:url] xml.method resp[:method] xml.id resp[:id], 'type' => 'integer' xml.body do xml.cdata! resp[:body] end end end end end header = HeaderHash.new header['Content-Type'] = 'application/xml; charset=utf-8' header['Content-Length'] = response_body.size.to_s header['Cache-Control'] = 'private, max-age=0, must-revalidate' first_response.instance_variable_set(:@body, response_body) [ 200, header, response_body] else @app.call(env) end end end
I know the code looks tricky, it realy is, but finally it allowed me to run the following test:
test "update many books" do Book.delete_all start = Time.now xml = Builder::XmlMarkup.new xml.requests 'type' => 'array' do xml.request do xml.url '/books.xml' xml.method 'POST' xml.id 0 xml.content_type 'application/xml' xml.body do xml.cdata! '<title>book2</title>' end end xml.request do xml.url '/books.xml' xml.method 'POST' xml.id 1 xml.content_type 'application/xml' xml.body do xml.cdata! '<title>book3</title>' end end xml.request do xml.url '/books.xml' xml.method 'GET' xml.id 2 end end assert_difference "Book.count", 2 do post '/super_action.xml', xml.target! end responses = Hash.from_xml( response.body )['responses'] stop = Time.now puts "separate:#{stop-start}:" y [ xml.target!, response.body ] assert_response :success assert_equal 3, responses.size responses.sort! { |a,b| a['id'] b['id'] } assert_equal 0, responses[0]['id'] assert_equal 'book2', Hash.from_xml(responses[0]['body'])['book']['title'] assert_equal 1, responses[1]['id'] assert_equal 'book3', Hash.from_xml(responses[1]['body'])['book']['title'] assert_equal 2, responses[2]['id'] assert_equal 'book2', Hash.from_xml(responses[2]['body'])['books'][0]['title'] assert_equal 'book3', Hash.from_xml(responses[2]['body'])['books'][1]['title'] end
Speed up in integration tests is over 2x on three actions, this makes this something considerable … only if the code would be not so tricky.