Rails is a large framework, and it grows larger every year. This makes it easy for some helpful features to slip by unnoticed. In this series, we'll take a look at some lesser-known functionality built into Rails for specific tasks.
In the first article in this series, we're going to take a look at how calling Rails.env.test?
actually works under-the-hood by using the little-known StringInquirer
class from ActiveSupport. Going one step further, we will also walk through the StringInquirer
source code to see how it works, which (spoiler alert) is a simple example of Ruby's metaprogramming via the special method_missing
method.
The Rails.env helper
You've probably already seen code that checks the Rails environment:
if Rails.env.test?
# return hard-coded value...
else
# contact external API...
end
However, what does test?
actually do, and where does this method come from?
If we check Rails.env
in a console, it acts like a string:
=> Rails.env
"development"
The string here is whatever the RAILS*ENV environment variable is set to; it's not _just* a String, though. When reading the class in the console, we see:
=> Rails.env.class
ActiveSupport::StringInquirer
Rails.env
is actually a StringInquirer.
StringInquirer
The code for StringInquirer is both brief and well documented. How it works, though, may not be obvious unless you are familiar with Ruby's metaprogramming capabilities. We'll walk through what's happening here.
class StringInquirer < String
...
end
First, we see that StringInquirer is a subclass of String. This is why Rails.env
acts like a String when we call it. By inheriting from String, we automatically get all the String-like functionality, so we can treat it like a String. Rails.env.upcase
works, as does ActiveRecordModel.find_by(string_column: Rails.env)
.
While Rails.env
is a handy, built-in example of StringInquirer, we are also free to create our own:
def type
result = "old"
result = "new" if @new
ActiveSupport::StringInquirer.new(result)
end
Then, we get the question-mark methods on the returned value:
=> @new = false
=> type.old?
true
=> type.new?
false
=> type.testvalue?
false
=> @new = true
=> type.new?
true
=> type.old?
false
method_missing
method_missing
is the real secret sauce here. In Ruby, whenever we call a method on an object, Ruby begins by looking at all the ancestors of the object (i.e., base classes or included modules) for the method. If one is not found, Ruby will call method_missing
, passing in the name of the method it is looking for, along with any arguments.
By default, this method just raises an exception. We're all probably familiar with the NoMethodError: undefined method 'test' for nil:NilClass
type of error message. We can implement our own method_missing
that does not raise an exception, which is exactly what StringInquirer
does:
def method_missing(method_name, *arguments)
if method_name.end_with?("?")
self == method_name[0..-2]
else
super
end
end
For any method name that ends in ?
, we check the value of self
(i.e., the String) against the method name without the ?
. Put another way, if we call StringInquirer.new("test").long_test_method_name?
, the returned value is "test" == "long_test_method_name"
.
If the method name does not end with a question mark, we fall back to the original method_missing
(the one that will raise an exception).
respond_to_missing?
There's one more method in the file: respond_to_missing?
. We might say that this is a companion method to method_missing
. Although method_missing
gives us the functionality, we also need a way to tell Ruby that we accept these question-mark-ending methods.
def respond_to_missing?(method_name, include_private = false)
method_name.end_with?("?") || super
end
This comes into play if we call respond_to?
on this object. Without this, if we called StringInquirer.new("test").respond_to?(:test?)
, the result would be false
because we have no explicit method called test?
. This is obviously misleading because I would expect it to return true if I call Rails.env.respond_to?(:test?)
.
respond_to_missing?
is what allows us to say "yes, I can handle that method" to Ruby. If the method name does not end in a question mark, we fall back to the superclass method.
Practical use cases
Now that we know how StringInquirer works, let's take a look at instances where it could be useful:
1. Environment variables
Environment variables meet two criteria that make them great choices for StringInquirer. First, they often have a limited, known set of possible values (like an enum
), and second, we often have conditional logic that depends on their value.
Let's say our app is hooked up to a payment API, and we have the credentials stored in environment variables. In our production system, this is obviously the real API, but in our staging or development version, we want to use their sandbox API:
# ENV["PAYMENT_API_MODE"] = sandbox/production
class PaymentGateway
def api_mode
# We use ENV.fetch because we want to raise if the value is missing
@api_mode ||= ENV.fetch("PAYMENT_API_MODE").inquiry
end
def api_url
# Pro-tip: We *only* use production if MODE == 'production', and default
# to sandbox if the value is anything else, this prevents us using production
# values if the value is mistyped or incorrect
if api_mode.production?
PRODUCTION_URL
else
SANDBOX_URL
end
end
end
Note that in the above, we are using ActiveSupport's String#inquiry
method, which handily converts a String into a StringInquirer for us.
2. API responses
Continuing our payment API example from above, the API will send us a response that includes some success/failure state. Again, this meets the two criteria that make it a candidate for StringInquirer: a limited set of possible values and conditional logic that will test these values.
class PaymentGateway
def create_charge
response = JSON.parse(api_call(...))
result = response["result"].inquiry
if result.success?
...
else
...
end
# result still acts like a string
Rails.logger.info("Payment result was: #{result}")
end
end
Conclusion
StringInquirer is an interesting tool to have in your back pocket, but personally, I wouldn't reach for it too often. It has some uses, but most of the time, an explicit method on an object gives you the same result. An explicitly named method also has a couple of benefits; if the value ever needs to change, you only have to update a single place, and it makes the codebase easier to search if a developer is trying to locate the method.
Although it focuses on StringInquirer, this article is intended to be more of a gentle introduction to some of Ruby's metaprogramming capabilities using method_missing
. I would say that you probably don't want to use method_missing
in your application. However, it is often used in frameworks, such as Rails or domain-specific-languages provided by gems, so it can be very helpful to know "how the sausage is made" when you encounter issues.
Top comments (0)