November 07, 2010
#22 Code Reading - The Twitter Gem
Just recently, the release candidate for the 1.0 version of the Twitter gem became available. Apparently this version is a major overhaul of previous versions. In our never ending quest to become better programmers, we'll read through some of the Twitter gem code before using it.
Configuration
Looking at the README file, we see how to setup the Twitter configuration.
What's going on when we put this code into our app? If we open configuration.rb, we see the Configuration module. At the top, a number of constants are set. For VALID_OPTIONS_KEYS, getter and setter instance methods are created via attr_accessor. (The splat operator ' * ' is used to expand VALID_OPTIONS_KEYS so that each array member is passed to attr_accessor.)
So where is the Configuration module used? If we look in twitter.rb, we see that it is extended into the base Twitter module. Because we extend the module, the instance methods defined in the Configuration module become class/module methods in the base Twitter module. Now we can call 'Twitter.consumer_key'. But instead, we make use of the convienence method 'configure'. This method simply yields 'self' (which in this context is the base Twitter module) to the supplied block so that it can be assigned to a temporary variable 'config' and other configuration parameters can be set.
In the Configuration module, there is a method 'self.extended(base)' that is defined. This is a callback/hook similar to 'self.included()' which we saw in Post #16. So, when the base Twitter module extends Configuration, this extended() method will automatically be called and some of the configuration settings will be reset to their default values.
So where is the Configuration module used? If we look in twitter.rb, we see that it is extended into the base Twitter module. Because we extend the module, the instance methods defined in the Configuration module become class/module methods in the base Twitter module. Now we can call 'Twitter.consumer_key'. But instead, we make use of the convienence method 'configure'. This method simply yields 'self' (which in this context is the base Twitter module) to the supplied block so that it can be assigned to a temporary variable 'config' and other configuration parameters can be set.
In the Configuration module, there is a method 'self.extended(base)' that is defined. This is a callback/hook similar to 'self.included()' which we saw in Post #16. So, when the base Twitter module extends Configuration, this extended() method will automatically be called and some of the configuration settings will be reset to their default values.
README.mkd, lib/twitter/configuration.rb and lib/twitter.rb
# README.mkd Twitter.configure do |config| config.consumer_key = YOUR_CONSUMER_KEY config.consumer_secret = YOUR_CONSUMER_SECRET ... end ________________________________________ # configuration.rb module Twitter module Configuration VALID_OPTIONS_KEYS = [:consumer_key, ...] ... attr_accessor *VALID_OPTIONS_KEYS def self.extended(base) base.reset end def configure yield self end def options VALID_OPTIONS_KEYS.inject({}){|o, k| o.merge!(k => send(k))} end def reset self.adapter = DEFAULT_ADAPTER ... end ________________________________________ # twitter.rb module Twitter extend Configuration
The Base Twitter Module
Looking at the other methods in the base Twitter module, we see that this module is basically a delagator to the Twitter::Client module. This is for convience. So instead of writing 'Twitter::Client.new', we can just write 'Twitter.client'.
The module takes advantage of method_missing. If we try to call a method that is not defined in the base Twitter module, then method_missing is invoked. Normally, this would raise an error message, but here it checks to see if the method is defined in Twitter::Client first. If the method exists there, the call is sent to a new instance of Client, otherwise the original method_missing defined in Ruby's Kernal module is called and an error is raised.
The module takes advantage of method_missing. If we try to call a method that is not defined in the base Twitter module, then method_missing is invoked. Normally, this would raise an error message, but here it checks to see if the method is defined in Twitter::Client first. If the method exists there, the call is sent to a new instance of Client, otherwise the original method_missing defined in Ruby's Kernal module is called and an error is raised.
lib/twitter.rb and Rails Console
# twitter.rb module Twitter extend Configuration def self.client(options={}) Twitter::Client.new(options) end def self.method_missing(method, *args, &block) return super unless client.respond_to?(method) client.send(method, *args, &block) end end ________________________________________ # Rails Console > Twitter.user('arailsdemo') => <#Hashie::Mash contributors_enabled=false ...> > Twitter.foo => NoMethodError: undefined method `foo' for Twitter:Module
The Twitter Client
In client.rb, the Client class inherits from 'API' and then include a bunch of modules, one of them being Twitter::Client::Tweets.
The API class is simple. It allows us to change some of the configuration settings on initialization. Also, it includes the Connection, Request, and Authentication modules.
The API class is simple. It allows us to change some of the configuration settings on initialization. Also, it includes the Connection, Request, and Authentication modules.
lib/twitter/client.rb and lib/twitter/api.rb
# client.rb module Twitter class Client < API ... include Twitter::Client::Tweets ... include Twitter::Client::User ... ________________________________________ # api.rb module Twitter class API attr_accessor *Configuration::VALID_OPTIONS_KEYS def initialize(options={}) options = Twitter.options.merge(options) Configuration::VALID_OPTIONS_KEYS.each do |key| send("#{key}=", options[key]) end end include Connection include Request include Authentication
Sending a Twitter Update
Creating a Twitter update is simple with this gem. All we need to do is to create a new client and call #update. That's great news because it saves us from a lot of code otherwise.
Before we get to that method, we should note that our basic interaction with the Twitter website is through their REST API. (Searching through Twitter is done through a separate Search API.) So if we want to create a tweet, we need to send a POST request to this Twitter URL: http://api.twitter.com/version/statuses/update.format. If we look back into the Configuration module, we see the first part of this URL defined as a constant.
The update method is in tweets.rb. As expected, it calls #post with the rest of the Twitter URL that we want to call. #update also takes the tweet message and stores it in the options hash. #post is in request.rb and is basically the #request method in different form. #request then calls #connection.
Before we get to that method, we should note that our basic interaction with the Twitter website is through their REST API. (Searching through Twitter is done through a separate Search API.) So if we want to create a tweet, we need to send a POST request to this Twitter URL: http://api.twitter.com/version/statuses/update.format. If we look back into the Configuration module, we see the first part of this URL defined as a constant.
The update method is in tweets.rb. As expected, it calls #post with the rest of the Twitter URL that we want to call. #update also takes the tweet message and stores it in the options hash. #post is in request.rb and is basically the #request method in different form. #request then calls #connection.
REAMDE.mkd, lib/twitter/configuration.rb, lib/twitter/client/tweets.rb, lib/twitter/request.rb
# README.mkd client = Twitter::Client.new client.update("I just posted a status update via the Twitter Ruby Gem!") ________________________________________ # configuration.rb module Twitter module Configuration ... DEFAULT_ENDPOINT = 'https://api.twitter.com/1/'.freeze ________________________________________ # tweets.rb module Twitter class Client module Tweets ... def update(status, options={}) response = post('statuses/update', options.merge(:status => status)) format.to_s.downcase == 'xml' ? response['status'] : response end ________________________________________ # request.rb module Twitter module Request ... def post(path, options={}, raw=false) request(:post, path, options, raw) end private def request(method, path, options, raw) response = connection(raw).send(method) do |request| ...
Establishing The Connection To Twitter
In the Connection module, #connection builds some options for our POST request. The actual POST request, though, is going to be delegated to the Faraday gem.
Faraday is a 'modular HTTP client library using middleware heavily inspired by Rack.' The Faraday::Connection object will be built to use various middleware. The Parse and Mashify classes are defined in the faraday_middleware gem. Parse will parse json and xml responses from Twitter. Mashify will take the response hash and convert it to a Hashie::Mash object using the hashie gem. This will allow "you to create pseudo-objects that have method-like accessors for hash keys."
The other classes are defined in the Twitter gem. Multipart will handle files (such as images) from Twitter. The RaiseHttp4xx and RaiseHttp5xx classes will handle the HTTP status codes for client and server errors respectively. For some of our methods like Twitter::Client#update, we'll have to supply the OAuth credentials that we defined in the configuration. This is done with the OAuth class with the help of the simple_oath gem to build the OAuth headers.
Faraday is a 'modular HTTP client library using middleware heavily inspired by Rack.' The Faraday::Connection object will be built to use various middleware. The Parse and Mashify classes are defined in the faraday_middleware gem. Parse will parse json and xml responses from Twitter. Mashify will take the response hash and convert it to a Hashie::Mash object using the hashie gem. This will allow "you to create pseudo-objects that have method-like accessors for hash keys."
The other classes are defined in the Twitter gem. Multipart will handle files (such as images) from Twitter. The RaiseHttp4xx and RaiseHttp5xx classes will handle the HTTP status codes for client and server errors respectively. For some of our methods like Twitter::Client#update, we'll have to supply the OAuth credentials that we defined in the configuration. This is done with the OAuth class with the help of the simple_oath gem to build the OAuth headers.
lib/twitter/connection.rb, lib/twitter/authentication.rb and twitter/lib/faraday/oauth.rb
# connection.rb require 'faraday_middleware' module Twitter module Connection private def connection(raw=false) options = { :headers => {'Accept' => "application/#{format}", 'User-Agent' => user_agent}, :proxy => proxy, :ssl => {:verify => false}, :url => api_endpoint, } Faraday::Connection.new(options) do |connection| connection.use Faraday::Request::Multipart connection.use Faraday::Request::OAuth, authentication if authenticated? connection.adapter(adapter) connection.use Faraday::Response::RaiseHttp5xx connection.use Faraday::Response::Parse unless raw connection.use Faraday::Response::RaiseHttp4xx connection.use Faraday::Response::Mashify unless raw end end end ________________________________________ # autentication.rb module Twitter module Authentication private def authentication { :consumer_key => consumer_key, :consumer_secret => consumer_secret, :token => oauth_token, :token_secret => oauth_token_secret } end def authenticated? authentication.values.all? end end end ________________________________________ # oauth.rb require 'faraday' require 'simple_oauth' module Faraday class Request::OAuth < Faraday::Middleware def call(env) params = env[:body].is_a?(Hash) ? env[:body] : {} signature_params = params.reject{|k,v| v.respond_to?(:content_type) } header = SimpleOAuth::Header.new(env[:method], env[:url], signature_params, @options) env[:request_headers]['Authorization'] = header.to_s @app.call(env) end def initialize(app, options) @app, @options = app, options end end end
Back To #request
The call to #connection will return a Faraday::Connection instance. In #request, we will then send a message to this instance telling it to run its #post method with the given block. (This is an example of the metaprogramming technique called 'dynamic dispatch.'
#post is basically the #run_request method, and in this method is where the block that we provided earlier will be yielded to. In our case, the block will set the request path and body (which contains our tweet.) The actual HTTP request is made through the Faraday::Request class (not shown.)
#post is basically the #run_request method, and in this method is where the block that we provided earlier will be yielded to. In our case, the block will set the request path and body (which contains our tweet.) The actual HTTP request is made through the Faraday::Request class (not shown.)
lib/twitter/request.rb, faraday/lib/faraday/connection.rb
# request.rb module Twitter module Request ... private def request(method, path, options, raw) response = connection(raw).send(method) do |request| case method when :get, :delete request.url(formatted_path(path), options) when :post, :put request.path = formatted_path(path) request.body = options end end raw ? response : response.body end def formatted_path(path) [path, format].compact.join('.') end ________________________________________ # connection.rb module Faraday class Connection ... def post(url = nil, body = nil, headers = nil, &block) run_request(:post, url, body, headers, &block) end ... def run_request(method, url, body, headers) if !METHODS.include?(method) raise ArgumentError, "unknown http method: #{method}" end Request.run(self, method) do |req| req.url(url) if url req.headers.update(headers) if headers req.body = body if body yield req if block_given? end end
The End Result
With all of that code that the Twitter gem developers have thankfully allowed us to use, we can easily make a tweet to our Twitter account.
Rails Console
> client = Twitter.client > response = client.update('test') => <#Hashie::Mash contributors=nil coordinates=nil ...> > response.user.url => "http://www.arailsdemo.com" > Twitter.oauth_token = 'bad_token' > client = Twitter.client > client.update('test') => Twitter::Unauthorized: POST https://api.twitter.com/1/statuses/update.json: 401 Unauthorized: Invalid / expired Token ...
(Please login to submit comments.)
Comments