DEV Community

Vinicius Stock
Vinicius Stock

Posted on • Edited on

Make a Ruby gem configurable

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (7)

Collapse
 
mereghost profile image
Marcello Rocha

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.

Collapse
 
vinistock profile image
Vinicius Stock

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.

Collapse
 
mereghost profile image
Marcello Rocha

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.

Collapse
 
wulymammoth profile image
David • Edited

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?

Collapse
 
vinistock profile image
Vinicius Stock

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.

Collapse
 
wulymammoth profile image
David • Edited

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

Collapse
 
vinistock profile image
Vinicius Stock • Edited

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 :)