Refactoring Patterns: The Rails Middleware Response Handler
Patrick Reagan, Former Development Director
Article Category:
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.