When authoring web service clients, Ruby Gem developers have a bad habit of forcing users to configure the client at the class level. This limits the flexibility of the client to a single set of configuration parameters at a time. Since most clients require authentication credentials, forcing the user to set them at the class level means only one account may be accessed with the client per application.
I mentioned this issue briefly in Raising the Bar, but I want to expand on it further here. I started work on a new client library this weekend, and decided to try to accommodate both of these scenarios the best I could:
- class-level configuration to cover the most common use cases
- instant-level configuration to allow multiple instances to be used when needed
The approach I settled on uses a top-level module to store a global instance for the former scenario and a client class that can be used directly to satisfy the latter.
Here's what a stripped-down client class might look like with a single configuration parameter for an API key.
module Service class Client attr_accessor :api_key def configure yield(self) self end end end
This arrangement easily allows for our second use-case:
client = Service::Client.new client.configure do |config| config.api_key = API_KEY end
Now, about that class-level configuration. Here's the definition for the
top-level module. The
extend self line effectively means we want to treat all
the methods defined in this module as module methods.
module Service extend self def respond_to?(method, include_private=false) client.respond_to?(method) || super end def method_missing(method, *args, &block) if client.respond_to?(method) client.send(method, *args, &block) else super end end def client @client ||= Client.new end private :client end
There's a decent bit of code here, but most of it should look familiar to
anyone who has used Ruby's
method_missing before. The basic idea is to
delegate the method calls on the
Service module to a global instance of
Service::Client stored as an instance variable on the
Service module. In
effect, this behaves a bit like a class variable, and it gives us the concept
of a global instance of the client.
We can configure this global instance like so:
Service.configure do |config| config.api_key = API_KEY end
Any other methods implemented on the client will be available for the user to
call directly on
This approach gives us the best of both worlds by allowing both class-level and instance-level configuration of the client. Please spend the time to think about how to implement an approach like this in your own Gems, and someday it will pay off by bringing happiness to the lives of developers the world over.