Refactoring Patterns: The Rails Middleware Response Handler

Patrick Reagan, Former Development Director

Article Category: #Code

Posted on

Recently, I have found myself writing more middlewares to handle responses that need to live outside of the main Rails application. The API that Rack provides works well for something trivial, maybe where you want to allow a load balancer to query the application's status:


class HealthCheck
 class Middleware

 def initialize(application)
 @application = application
 end

 def call(environment)
 if environment['PATH_INFO'] == '/health-check'
 if HealthCheck.healthy?
 [200, {}, ['OK']]
 else
 [500, {}, ['PROBLEM']]
 end
 else
 @application.call(environment)
 end
 end
 end
end
 

I'm sure you've seen something similar to the above code in some projects you've worked on — it's not particularly offensive, so there's no urgent need to refactor. But what about those times when your middleware is more complex than just a simple health check?

Consider the example of delivering a mobile-optimized version of your site to your clients:


module Mobile
 class Format

 def initialize(application)
 @application = application
 end

 def call(environment)
 request = Rack::Request.new(environment)

 user_agent = ::Mobile::UserAgent.new(request.env['HTTP_USER_AGENT'])

 if request.params['mobile'] == '1' || user_agent.mobile?
 request.env['HTTP_ACCEPT'] = 'text/html+mobile'
 end

 status, headers, body = @application.call(request.env)

 response = Rack::Response.new(body, status, headers)
 response.finish
 end
 end
end
 

Although this code functions as expected, it's not particularly clear what's going on to someone who will later need to maintain this codebase. In addition to ensuring that my code works, part of my job as a programmer is to create code that's easy to understand and, by extension, maintain. Working within the constraints of the middleware API, I might opt for some named abstractions to improve understandability:


module Mobile
 class Format

 def initialize(application)
 @application = application
 end

 def call(environment)
 request = Rack::Request.new(environment)

 set_mobile_accept_header(request) if serve_mobile?(request)
 response = response(request)
 response.finish
 end

 private

 def user_agent(request)
 Mobile::UserAgent.new(request.env['HTTP_USER_AGENT'])
 end

 def mobile_requested?(request)
 request.params['mobile'] == '1'
 end

 def serve_mobile?(request)
 mobile_requested?(request) || user_agent(request).mobile?
 end

 def set_mobile_accept_header(request)
 request.env['HTTP_ACCEPT'] = 'text/html+mobile'
 end

 def response(request)
 status, headers, body = @application.call(request.env)
 Rack::Response.new(body, status, headers)
 end

 end
end
 

This refactoring is a step toward the goals of clarity and maintainability, but this code could still be improved. The passing of request to supporting methods is a code smell. It suffers from two problems — it decreases the clarity of my named abstractions and it breaks encapsulation. To fix this problem, you might be tempted to store environment as an instance variable and instead lazily load request:


module Mobile
 class Format

 def initialize(application)
 @application = application
 end

 def call(environment)
 @environment = environment

 set_mobile_accept_header if serve_mobile?
 response.finish
 end

 private

 def request
 @request ||= Rack::Request.new(@environment)
 end

 def user_agent
 Mobile::UserAgent.new(request.env['HTTP_USER_AGENT'])
 end

 def mobile_requested?
 request.params['mobile'] == '1'
 end

 def serve_mobile?
 mobile_requested? || user_agent.mobile?
 end

 def set_mobile_accept_header
 request.env['HTTP_ACCEPT'] = 'text/html+mobile'
 end

 def response
 status, headers, body = @application.call(request.env)
 Rack::Response.new(body, status, headers)
 end

 end
end
 

While it looks like this solves our problems, lazily loading request actually introduces a subtle bug. Because the value is memoized across all requests, the first request to this middleware will determine the result for subsequent requests. This means if the first client is to be served the mobile version of the site, then all other clients will see that version as well.

Out of the options I've explored above, none are viable solutions for a production application. Instead, I opt for an approach that I've found successful on some of our recent projects that I have been calling the "middleware response handler":


module Mobile
 class Format

 def initialize(application)
 @application = application
 end

 def call(environment)
 responder = Mobile::Format::Responder.new(@application, environment)
 responder.respond
 end

 class Responder

 def initialize(application, environment)
 @application = application
 @environment = environment
 end

 def respond
 set_mobile_accept_header if serve_mobile?
 response.finish
 end

 private

 def request
 @request ||= Rack::Request.new(@environment)
 end

 def user_agent
 ::Mobile::UserAgent.new(request.env['HTTP_USER_AGENT'])
 end

 def mobile_requested?
 request.params['mobile'] == '1'
 end

 def serve_mobile?
 mobile_requested? || user_agent.mobile?
 end

 def set_mobile_accept_header
 request.env['HTTP_ACCEPT'] = 'text/html+mobile'
 end

 def response
 @response ||= begin
 status, headers, body = @application.call(request.env)
 Rack::Response.new(body, status, headers)
 end
 end
 end
 end
end
 

The key change here is that by introducing the Mobile::Format::Responder class that encapsulates environment, I can more easily perform an extract method refactoring and improve the readability of this code. Additionally, each time that this middleware is invoked, I'm guaranteed to have a new instance of the responder — I won't see state bleeding over from previous requests. In my experience on projects like Puma, TimeLife, and Shure, this strategy has been a great way to significantly improve the maintainability of our middleware code.

The above code is also available as a Gist for your forking pleasure.

Related Articles