Rails' Hidden Gems: ActiveSupport StringInquirer

If you've ever checked the environment in your Rails app with Rails.env.production? you've used a fascinating little utility class called StringInquirer. In this post, Jonathan Miles dives into the rails codebase to show us exactly how StringInquirer works and how we can bring a little of its magic to our own apps.

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.

author photo

Jonathan Miles

Jonathan began his career as a C/C++ developer but has since transitioned to web development with Ruby on Rails. 3D printing is his main hobby but lately all his spare time is taken up with being a first-time dad to a rambunctious toddler.


Author's Website
“We’ve looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release.”
Michael Smith
Try Error Monitoring Free for 15 Days
Are you using Bugsnag, Rollbar, or Airbrake for your monitoring? Honeybadger includes exception, uptime, and check-in monitoring — all for probably less than you’re paying now. Discover why so many companies are switching to Honeybadger here.
Try Error Monitoring Free for 15 Days
Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
Try Error Monitoring Free for 15 Days