Responsible monkey patching with Module#prepend
As discussed earlier this week, changing the behaviour of existing code via monkey patching is generally inadvisable, but there are cases where it's the most convenient solution to a problem.
There are more elegant ways to override methods than just reopening the class and redefining them, though. By leveraging Module#prepend we can insert a module into the ancestry chain of the class we want to modify. This has the benefit of allowing us to call the original method via super and also makes it clearer that we're doing something out of the ordinary:
module ChompingLines
# NOTE: changes the default value of chomp to true
def lines(record_separator = $/, chomp: true)
super
end
end
"line1\nline2\n".lines
# => ["line1\n", "line2\n"]
String.prepend ChompingLines
String.ancestors
# => [ChompingLines, String, ...]
"line1\nline2\n".lines
# => ["line1", "line2"]
Here's an example of how we use this technique in one of Mynewsdesk's Rails applications to prevent cookies from being shared across different subdomains when the domain: :all option is used:
# Since we need cookies to be shared across subdomains (www, publish etc)
# we've been in the habit of using the domain: :all option for Rails session
# and other cookies. This makes Rails set cookies at the root domain which
# allows them to be shared across all subdomains.
#
# The issue with this approach arises when mounting the Rails app on different
# subdomains as we do on mnd-review.com, e.g. pull-request-1.mnd-review.com and
# pull-request-2.mnd-review.com. Since cookies are set on .mnd-review.com, they end up
# shared across different apps, wreaking havoc in the process.
#
# To mitigate this issue, this monkey patch intercepts any cookies set with the
# "all" option and sets them on HTTP_DOMAIN instead. It also checks if
# request.host matches HTTP_DOMAIN to avoid messing up hosted newsrooms.
module DefaultCookieDomain
# The handle_options method gets called by all methods that set and delete cookies
def handle_options(options)
if options[:domain].to_s == "all" && request.host.include?(HTTP_DOMAIN)
options[:domain] = HTTP_DOMAIN
end
super
end
end
ActionDispatch::Cookies::CookieJar.prepend(DefaultCookieDomain)
Note of caution: In these kinds of circumstances, when we are not in control of the code we're modifying, it's important to have tests covering the behaviour of the code we're changing to avoid accidentally breaking something.
History
Module#prepend was added in Ruby 2.0, in 2013, and has since become the idiomatic way to handle sensitive monkey patching in Ruby.
Before that, when we needed to modify the behaviour of an existing method and still wanted to be able to call the original method, we used to alias the original method before redefining it:
class String
alias_method :original_lines, :lines
def lines(record_separator = $/, chomp: true)
original_lines(record_separator, chomp: chomp)
end
end