Explaining magic behind popular Ruby code

Ruby language allows us to easily create beautiful DSL's and design complex libraries that anybody can easily use, despite programming experience. The code often looks perfect, but sometimes it is not clear how it was achieved under the hood. In this article, I explain a few solutions that are quite popular among many gems, and you can easily use it in your projects once you understand how you can build a similar code.

The config-way of setting variables

You can see this code in almost every Ruby gem that utilizes initializers to set some variables needed for later use:

SomeGem.configure do |config|
  config.api_key = 'api_key'
  config.app_name = 'My App'
end

This way of setting variables is very readable and easily extendable. It works like any other method that yields something. While the each method yields elements of the array, in the above example, the configure method yields the instance of the Configuration class (or any other). It allows us to set attributes of this class inside the block.

You can build your configurator this way:

module SomeGem
  class << self
    attr_accessor :configuration

    def configure
      self.configuration ||= Configuration.new
      yield(configuration)
    end
  end
  
  class Configuration
    attr_accessor :api_key, :app_name
  end
end

Let's give it a try:

SomeGem.configure do |config|
  config.api_key = 'api_key'
  config.app_name = 'My App'
end

SomeGem.configuration.api_key # => 'api_key'
SomeGem.configuration.app_name # => 'My App'

You can later access values just like for any other object.

Dynamic methods

If you ever used Ruby on Rails code, you probably saw that many methods refer to the model attributes. Since the code is not aware of the attribute names unless you define them, it dynamically creates a method, and you won't find the standard definition for them.

Let's see some examples assuming that you have the class User and first_name and last_name attributes defined on it:

user = User.new(first_name: "John", last_name: "Doe")
user.first_name_is?('John') # => true
user.last_name_is?('John') # => false

Of course, you can call user.first_name == 'John', but I decided to show a straightforward case for the demonstration purposes. Here is the definition of our class:

class User
  attr_reader :first_name, :last_name

  def initialize(first_name:, last_name:)
    @first_name = first_name
    @last_name = last_name
  end
end

Right now, when calling #first_name_is? or last_name_is_mike? we would get the NoMethodError error because we didn't define those methods. This error is the starting point of the implementation of dynamic methods. Ruby exposes the method_missing method to allow us to do something with the not existing method and decide if we want to raise the error:

class User
  attr_reader :first_name, :last_name

  def initialize(first_name:, last_name:)
    @first_name = first_name
    @last_name = last_name
  end

  private

  def method_missing(method_name, *args, &block)
    puts "You are missing #{method_name} method"
    super # raise the error anyway
  end
end

After rerunning the previous code, you should see the following text printed before the error is raised:

You are missing first_name_is? method

The method_missing method accepts three arguments:

  • method_name - the name of the method that doesn't exist yet
  • args - optional parameter; arguments passed to the method
  • block - optional parameter; the block executed on the method

If we want to create a dynamic method, we must first verify that it ends with _is? and starts with the existing attribute's name. Then we have to raise the error if the method name is invalid or called on a not defined attribute or compare the attribute value with the first argument and return the result:

def method_missing(method_name, *args, &block)
  attribute_name = method_name.to_s.match(/(.*)_is\?/)&.captures&.first
  
  if !attribute_name.nil? && instance_variable_defined?("@#{attribute_name}")
    instance_variable_get("@#{attribute_name}") == args.first
  else
    super
  end
end

def respond_to_missing?(method_name, include_private = false)
  method_name.to_s.end_with?('_is?') || super
end

Now we can play with our class and see that it's working:

user = User.new(first_name: 'John', last_name: 'Doe')
user.first_name_is?('John') # => true
user.last_name_is?('Tom') # => false

user.method(:first_name_is?).call('John') # => true

# call on not existing attribute
user.age_is?('John') # => NoMethodError

Remember to always define the respond_to_missing? method when overriding method_missing.

If you want to have more complex methods, you may want to create dynamic definitions.

Duck typing

Some objects behave differently when you call to_s on them (or any other method from the standard language library). For example, you might saw the following example:

response = Request.get('some_url')

puts response # => "status: 200, body: some page body"
puts response.status # => 200
puts response.body # => "some page body"

Let's create a sample definition of Response and Request class:

class Response
  attr_reader :status, :body
  
  def initialize(status:, body:)
    @status = status
    @body = body
  end
end

class Request
  def self.get(url)
    Response.new(status: 200, body: "some page body")
  end
end

Fine, but when calling the same code as before, we would not get the same result:

response = Request.get('some_url')

puts response # => #<Response:0x00007f9e3f1a0150>

It happens because each time you use puts, it calls the to_s method on the passed thing, and in our case, the default to_s method defined on an object is called. It returns the string that includes the class name.

If we want to change this behavior, we can define the to_s method:

class Response
  attr_reader :status, :body
  
  def initialize(status:, body:)
    @status = status
    @body = body
  end

  def to_s
    "status: #{@status}, body: #{@body}"
  end
end

Now it works as expected:

response = Request.get('some_url')

puts response # => "status: 200, body: some page body"

If it quacks like a duck, it's a duck. If it implements the to_s method, you can consider it as a string and use it as a string.