loading...
Cover image for Make a Ruby gem configurable

Make a Ruby gem configurable

vinistock profile image Vinicius Stock ・3 min read

In the Ruby ecosystem, a well established way of customizing gem behavior is by using configuration blocks. Most likely, you came across code that looked like this.

MyGem.configure do |config|
  config.some_config = true
  config.some_class = MyApp
  config.some_lambda = ->(variable) { do_important_stuff(variable) }
end

This standard way of configuring gems runs once, usually when starting an application, and allows developers to tailor the gem's functionality to their needs.

Adding the configuration block to a gem

Let's take a look at how this style of configuration can be implemented. We'll divide our implementation in two steps: writing a configuration class and exposing it to users.

Writing the configuration class

The configuration class is responsible for keeping all the available options and their defaults. It is composed of two things: an initialize method where defaults are set and attribute readers/writers.

Let's take a look at an example, where we can configure an animal and the sound that it makes. If the sound does not match the expected, we want to raise an error while configuring our gem. We'll then access what is configured for animal and sound and use it in our logic.

# lib/my_gem/configuration.rb

module MyGem
  class Configuration
    # Custom error class for sounds that don't
    # match the expected animal
    class AnimalSoundMismatch < StandardError; end

    # Animal sound map
    ANIMAL_SOUND_MAP = {
      "Dog" => "Barks",
      "Cat" => "Meows"
    }

    # Writer + reader for the animal instance variable. No fancy logic
    attr_accessor :animal

    # Reader only for the sound instance variable.
    # The writer contains custom logic
    attr_reader :sound

    # Initialize every configuration with a default.
    # Users of the gem will override these with their
    # desired values
    def initialize
      @animal = "Dog"
      @sound = "Barks"
    end

    # Custom writer for sound.
    # If the sound variable is not exactly what is
    # mapped in our hash, raise the custom error
    def sound=(sound)
      raise AnimalSoundMismatch, "A #{@animal} can't #{sound}" if SOUND_MAP[@animal] != sound

      @sound = sound
    end
  end
end

Exposing the configuration

To expose the configuration means both letting users configure it and allowing the gem itself to read the value of each option.

This is done with a singleton of our Configuration class and a few utility methods.

# lib/my_gem.rb

module MyGem
  class << self
    # Instantiate the Configuration singleton
    # or return it. Remember that the instance
    # has attribute readers so that we can access
    # the configured values
    def configuration
      @configuration ||= Configuration.new
    end

    # This is the configure block definition.
    # The configuration method will return the
    # Configuration singleton, which is then yielded
    # to the configure block. Then it's just a matter
    # of using the attribute accessors we previously defined
    def configure
      yield(configuration)
    end
  end
end

The gem is now set to be configured by the applications that use it.

MyGem.configure do |config|
  # Notice that the config block argument
  # is the yielded singleton of Configuration.
  # In essence, all we're doing is using the
  # accessors we defined in the Configuration class
  config.animal = "Cat"
  config.sound = "Meows"
end

Using the configuration in the gem

Now that we have the customizable Configuration singleton, we can read the values to change behavior based on it.

# lib/my_gem/make_sound.rb

module MyGem
  class AnimalSound
    def initialize
      @animal = MyGem.configuration.animal
      @sound = MyGem.configuration.sound
    end

    def make_sound
      "The #{@animal} #{@sound}"
    end
  end
end

Using lambdas for configuration

In specific scenarios, there may be a need for a configuration to not have a predetermined value, but rather to evaluate some logic as the application is running.

For these cases, it is typical to define a lambda for the configuration value. Let's go through an example. The configuration class is similar to our previous case.

# lib/my_gem/configuration.rb

module MyGem
  class Configuration
    attr_reader :key_name

    # Define no lambda as the default
    def initialize
      @key_name = nil
    end

    # Raise an error if trying to set the key_name
    # to something other than a lambda
    def key_name=(lambda)
      raise ArgumentError, "The key_name must be a lambda" unless lambda.is_a?(Proc)

      @key_name = lambda
    end
  end
end

Now we can configure the lambda to whatever we need. You could even query the database if desired inside the lambda and return values from the app's models.

MyGem.configure do |config|
  config.lambda_config = ->(model_name) { model_name == "Post" ? :posts : :articles }
end

Finally, the gem can use the lambda and get different results as the app is running.

MyGem.configuration.key_name&.call("Article")
=> :articles

MyGem.configuration.key_name&.call("Post")
=> :posts

That's about it for configuration blocks. Have you used this before to make your code/gems customizable? Do you know other strategies for configuring third party libraries? Let me know in the comments!

Posted on by:

vinistock profile

Vinicius Stock

@vinistock

Dev @ Shopify. Ruby & Rails open source contributor

Discussion

markdown guide
 

Some gems also apply a more DSL-like approach:

MyGem.configure do
  some_config true
  some_class MyApp
  some_lambda ->(variable) { do_important_stuff(variable) }
end

This is usually achieved by using instance_eval to evaluate the block in the context of the configuration object and methods that look like getters but can also be used as setters.

BTW: your example used the wrong syntax for the stabby lambda (happens to me all the time too), I changed it here.

 

Thanks for the heads up! Fixed the lambda syntax. I like the DSL approach too. The only inconvenience is that editors usually won't autocomplete the options for you, but with proper docs that's easy to overcome.

I actually wrote a bit on writing DSLs with instance_eval :)

 

Unless I'm trying to avoid dependencies, my usual go to for configuration is dry-configurable as it provides a nice way to to define, even deeply nested, settings.

As a bonus you get settings thread safety.

 

Oh, nice! I had not heard about that one, will have to check it out. And yeah, the solution in this article is not thread safe.

 

Dry-rb has a lot of nice gems with basically no heavy dependencies.

Totally worth a look. Maybe worth an internal presentation @ Shopify, fellow Shopifolk.

 

Love that you wrote about this — I see a lot of code that has instance variables that are really configuration vars. I, too, have been guilty of this, whenever I touch code and find myself altering one, I see if it’s too much effort. Most of the time things were simple enough that just needed a hash of key-values needing only a setter and getter. What I end up using is ActiveSupport Configurable. Are you familiar with it and do you use or advise the use of it?

 

I know it exists, but haven't used it myself. Do you find it more convenient? The only drawback in my opinion would be adding activesupport as a dependency if all you need is the configurable part.

 

I do! I've read about the different ways to do configurations, several blog posts from different people that I admire. I can't seem to find it in my bookmarks right now, but it's actually how I discovered it... the below is literally pulled from the Rails docs. But yeah, I've done none of the other variants, and found yours very interesting.

require 'active_support/configurable'

class User
  include ActiveSupport::Configurable
end

user = User.new

user.config.allowed_access = true
user.config.level = 1

user.config.allowed_access # => true
user.config.level          # => 1

docs