joncanady-grackle 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/History.txt ADDED
@@ -0,0 +1,5 @@
1
+ == 0.1.0 (2009-04-12)
2
+ * Added OAuth authentication
3
+ * Deprecated :username and :password Grackle::Client constructor params
4
+ * Changed multipart upload implementation and removed dependency on httpclient gem
5
+ * Added dependency on mime-types gem
data/README.rdoc ADDED
@@ -0,0 +1,153 @@
1
+ =grackle
2
+ by Hayes Davis
3
+ - http://twitter.com/hayesdavis
4
+ - hayes [at] appozite.com
5
+ - http://cheaptweet.com
6
+ - http://www.appozite.com
7
+ - http://hayesdavis.net
8
+
9
+ == DESCRIPTION
10
+ Grackle is a lightweight Ruby wrapper around the Twitter REST and Search APIs. It's based on my experience using the
11
+ Twitter API to build http://cheaptweet.com. The main goal of Grackle is to never require a release when the Twitter
12
+ API changes (which it often does) or in the face of a particular Twitter API bug. As such it's somewhat different
13
+ from other Twitter API libraries. It doesn't try to hide the Twitter "methods" under an access layer nor does it
14
+ introduce concrete classes for the various objects returned by Twitter. Instead, calls to the Grackle client map
15
+ directly to Twitter API URLs. The objects returned by API calls are generated as OpenStructs on the fly and make no
16
+ assumptions about the presence or absence of any particular attributes. Taking this approach means that changes to
17
+ URLs used by Twitter, parameters required by those URLs or return values will not require a new release. It
18
+ will potentially require, however, some modifications to your code that uses Grackle.
19
+
20
+ Grackle supports both OAuth and HTTP basic authentication.
21
+
22
+ ==USING GRACKLE
23
+
24
+ Before you do anything else, you'll need to
25
+ require 'grackle'
26
+
27
+ ===Creating a Grackle::Client
28
+ ====Using Basic Auth
29
+ client = Grackle::Client.new(:auth=>{:type=>:basic,:username=>'your_user',:password=>'yourpass'})
30
+
31
+ ====Using OAuth
32
+ client = Grackle::Client.new(:auth=>{
33
+ :type=>:oauth,
34
+ :consumer_key=>'SOMECONSUMERKEYFROMTWITTER', :consumer_secret=>'SOMECONSUMERTOKENFROMTWITTER',
35
+ :token=>'ACCESSTOKENACQUIREDONUSERSBEHALF', :token_secret=>'SUPERSECRETACCESSTOKENSECRET'
36
+ })
37
+
38
+ ====Using No Auth
39
+ client = Grackle::Client.new
40
+
41
+ See Grackle::Client for more information about valid arguments to the constructor. It's quite configurable. Among other things,
42
+ you can turn on ssl and specify custom headers. The calls below are pretty much as simple as it gets.
43
+
44
+ ===Grackle Method Syntax
45
+ Grackle uses a method syntax that corresponds to the Twitter API URLs with a few twists. Where you would have a slash in
46
+ a Twitter URL, that becomes a "." in a chained set of Grackle method calls. Each call in the method chain is used to build
47
+ Twitter URL path until a particular call is encountered which causes the request to be sent. Methods which will cause a
48
+ request to be execute include:
49
+ - A method call ending in "?" will cause an HTTP GET to be executed
50
+ - A method call ending in "!" will cause an HTTP POST to be executed
51
+ - If a valid format such as .json, .xml, .rss or .atom is encounted, a get will be executed with that format
52
+ - A format method can also include a ? or ! to determine GET or POST in that format respectively
53
+
54
+ ===GETting Data
55
+ The preferred and simplest way of executing a GET is to use the "?" method notation. This will use the default client
56
+ format (usually JSON, but see Formats section below):
57
+ client.users.show? :screen_name=>'some_user' #http://twitter.com/users/show.json?screen_name=some_user
58
+
59
+ You can force XML format by doing:
60
+ client.users.show.xml? :screen_name=>'some_user' #http://twitter.com/users/show.xml?scren_name=some_user
61
+
62
+ You can force JSON:
63
+ client.users.show.json? :screen_name=>'some_user' #http://twitter.com/users/show.json?screen_name=some_user
64
+
65
+ Or, since Twitter also allows certain ids/screen_names to be part of their URLs, this works:
66
+ client.users.show.some_user? #http://twitter.com/users/show/some_user.json
67
+
68
+ If you use an explicit format, you can leave off the "?" like so:
69
+ client.users.show.xml :screen_name=>'some_user' #http://twitter.com/users/show.xml?scren_name=some_user
70
+
71
+ ===POSTing data
72
+ To use Twitter API methods that require an HTTP POST, you need to end your method chain with a bang (!)
73
+
74
+ The preferred way is to use the Client's default format (usually JSON, but see Formats section below):
75
+ client.statuses.update! :status=>'this status is from grackle' #POST to http://twitter.com/statuses/update.json
76
+
77
+ You can force a format. To update the authenticated user's status using the XML format:
78
+ client.statuses.update.xml! :status=>'this status is from grackle' #POST to http://twitter.com/statuses/update.xml
79
+
80
+ Or, with JSON
81
+ client.statuses.update.json! :status=>'this status is from grackle' #POST to http://twitter.com/statuses/update.json
82
+
83
+ ===Toggling APIs
84
+ By default, the Grackle::Client sends all requests to the Twitter REST API. If you want to send requests to the Twitter Search API, just
85
+ set Grackle::Client.api to :search. To toggle back, set it to be :rest. All requests made after setting this
86
+ attribute will go to that API.
87
+
88
+ If you want to make a specific request to one API and not change the Client's overall api setting beyond that request, you can use the
89
+ bracket syntax like so:
90
+ client[:search].trends.daily? :exclude=>'hashtags'
91
+ client[:rest].users.show? :id=>'hayesdavis'
92
+
93
+ Search and REST requests are all built using the same method chaining and termination conventions.
94
+
95
+ ===Parameter handling
96
+ - All parameters are URL encoded as necessary.
97
+ - If you use a File object as a parameter it will be POSTed to Twitter in a multipart request.
98
+ - If you use a Time object as a parameter, .httpdate will be called on it and that value will be used
99
+
100
+ ===Return Values
101
+ Regardless of the format used, Grackle returns an OpenStruct (actually a Grackle::TwitterStruct) of data. The attributes
102
+ available on these structs correspond to the data returned by Twitter.
103
+
104
+ ===Dealing with Errors
105
+ If the request to Twitter does not return a status code of 200, then a TwitterError is thrown. This contains the HTTP method used,
106
+ the full request URI, the response status, the response body in text and a response object build by parsing the formatted error
107
+ returned by Twitter. It's a good idea to wrap your API calls with rescue clauses for Grackle::TwitterError.
108
+
109
+ If there is an unexpected connection error or Twitter returns data in the wrong format (which it can do), you'll still get a TwitterError.
110
+
111
+ ===Formats
112
+ Twitter allows you to request data in particular formats. Grackle automatically parses JSON and XML formatted responses
113
+ and returns an OpenStruct. If you specify a format that Grackle doesn't parse for you, you'll receive a string containing
114
+ the raw response body. The Grackle::Client has a default_format you can specify. By default, the default_format is :json.
115
+ If you don't include a named format in your method chain as described above, but use a "?" or "!" then the
116
+ Grackle::Client.default_format is used.
117
+
118
+ == REQUIREMENTS:
119
+
120
+ You'll need the following gems to use all features of Grackle:
121
+ - json
122
+ - oauth
123
+ - mime-types
124
+
125
+ == INSTALL:
126
+
127
+ sudo gem sources -a http://gems.github.com
128
+ sudo gem install hayesdavis-grackle
129
+
130
+ == LICENSE:
131
+
132
+ (The MIT License)
133
+
134
+ Copyright (c) 2009
135
+
136
+ Permission is hereby granted, free of charge, to any person obtaining
137
+ a copy of this software and associated documentation files (the
138
+ 'Software'), to deal in the Software without restriction, including
139
+ without limitation the rights to use, copy, modify, merge, publish,
140
+ distribute, sublicense, and/or sell copies of the Software, and to
141
+ permit persons to whom the Software is furnished to do so, subject to
142
+ the following conditions:
143
+
144
+ The above copyright notice and this permission notice shall be
145
+ included in all copies or substantial portions of the Software.
146
+
147
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
148
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
149
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
150
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
151
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
152
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
153
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/grackle.gemspec ADDED
@@ -0,0 +1,40 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = %q{grackle}
5
+ s.version = "0.1.3"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["Hayes Davis", "Jon Canady"]
9
+ s.date = %q{2009-07-22}
10
+ s.description = %q{Grackle is a lightweight library for the Twitter REST and Search API.}
11
+ s.email = %q{jon@joncanady.com}
12
+ s.files = ["History.txt", "README.rdoc", "grackle.gemspec", "lib/grackle.rb", "lib/grackle/client.rb", "lib/grackle/handlers.rb", "lib/grackle/transport.rb", "lib/grackle/utils.rb", "test/test_grackle.rb", "test/test_helper.rb", "test/test_client.rb", "test/test_handlers.rb"]
13
+ s.has_rdoc = true
14
+ s.homepage = %q{http://github.com/joncanady/grackle}
15
+ s.rdoc_options = ["--inline-source", "--charset=UTF-8","--main=README.rdoc"]
16
+ s.extra_rdoc_files = ['README.rdoc']
17
+ s.require_paths = ["lib"]
18
+ s.rubyforge_project = %q{grackle}
19
+ s.rubygems_version = %q{1.3.1}
20
+ s.summary = %q{Grackle is a library for the Twitter REST and Search API designed to not require a new release in the face Twitter API changes or errors. It supports both basic and OAuth authentication mechanisms.}
21
+
22
+ if s.respond_to? :specification_version then
23
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
24
+ s.specification_version = 2
25
+
26
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
27
+ s.add_runtime_dependency(%q<json>, [">= 0"])
28
+ s.add_dependency(%q<mime-types>, [">= 0"])
29
+ s.add_dependency(%q<oauth>, [">= 0"])
30
+ else
31
+ s.add_dependency(%q<json>, [">= 0"])
32
+ s.add_dependency(%q<mime-types>, [">= 0"])
33
+ s.add_dependency(%q<oauth>, [">= 0"])
34
+ end
35
+ else
36
+ s.add_dependency(%q<json>, [">= 0"])
37
+ s.add_dependency(%q<mime-types>, [">= 0"])
38
+ s.add_dependency(%q<oauth>, [">= 0"])
39
+ end
40
+ end
@@ -0,0 +1,269 @@
1
+ module Grackle
2
+
3
+ #Returned by methods which retrieve data from the API
4
+ class TwitterStruct < OpenStruct
5
+ attr_accessor :id
6
+ end
7
+
8
+ #Raised by methods which call the API if a non-200 response status is received
9
+ class TwitterError < StandardError
10
+ attr_accessor :method, :request_uri, :status, :response_body, :response_object
11
+
12
+ def initialize(method, request_uri, status, response_body, msg=nil)
13
+ self.method = method
14
+ self.request_uri = request_uri
15
+ self.status = status
16
+ self.response_body = response_body
17
+ super(msg||"#{self.method} #{self.request_uri} => #{self.status}: #{self.response_body}")
18
+ end
19
+ end
20
+
21
+ # The Client is the public interface to Grackle. You build Twitter API calls using method chains. See the README for details
22
+ # and new for information on valid options.
23
+ #
24
+ # ==Authentication
25
+ # Twitter is migrating to OAuth as the preferred mechanism for authentication (over HTTP basic auth). Grackle supports both methods.
26
+ # Typically you will supply Grackle with authentication information at the time you create your Grackle::Client via the :auth parameter.
27
+ # ===Basic Auth
28
+ # client = Grackle.Client.new(:auth=>{:type=>:basic,:username=>'twitteruser',:password=>'secret'})
29
+ # Please note that the original way of specifying basic authentication still works but is deprecated
30
+ # client = Grackle.Client.new(:username=>'twitteruser',:password=>'secret') #deprecated
31
+ #
32
+ # ===OAuth
33
+ # OAuth is a relatively complex topic. For more information on OAuth applications see the official OAuth site at http://oauth.net and the
34
+ # OAuth specification at http://oauth.net/core/1.0. For authentication using OAuth, you will need do the following:
35
+ # - Acquire a key and token for your application ("Consumer" in OAuth terms) from Twitter. Learn more here: http://apiwiki.twitter.com/OAuth-FAQ
36
+ # - Acquire an access token and token secret for the user that will be using OAuth to authenticate into Twitter
37
+ # The process of acquiring the access token and token secret are outside the scope of Grackle and will need to be coded on a per-application
38
+ # basis. Grackle comes into play once you've acquired all of the above pieces of information. To create a Grackle::Client that uses OAuth once
39
+ # you've got all the necessary tokens and keys:
40
+ # client = Grackle::Client.new(:auth=>{
41
+ # :type=>:oauth,
42
+ # :consumer_key=>'SOMECONSUMERKEYFROMTWITTER, :consumer_secret=>'SOMECONSUMERTOKENFROMTWITTER',
43
+ # :token=>'ACCESSTOKENACQUIREDONUSERSBEHALF', :token_secret=>'SUPERSECRETACCESSTOKENSECRET'
44
+ # })
45
+ class Client
46
+
47
+ class Request #:nodoc:
48
+ attr_accessor :client, :path, :method, :api, :ssl
49
+
50
+ def initialize(client,api=:rest,ssl=true)
51
+ self.client = client
52
+ self.api = api
53
+ self.ssl = ssl
54
+ self.method = :get
55
+ self.path = ''
56
+ end
57
+
58
+ def <<(path)
59
+ self.path << path
60
+ end
61
+
62
+ def path?
63
+ path.length > 0
64
+ end
65
+
66
+ def url
67
+ "#{scheme}://#{host}#{path}"
68
+ end
69
+
70
+ def host
71
+ client.api_hosts[api]
72
+ end
73
+
74
+ def scheme
75
+ ssl ? 'https' :'http'
76
+ end
77
+ end
78
+
79
+ VALID_METHODS = [:get,:post,:put,:delete]
80
+ VALID_FORMATS = [:json,:xml,:atom,:rss]
81
+
82
+ TWITTER_API_HOSTS = {:rest=>'twitter.com',:search=>'search.twitter.com'}
83
+
84
+ #Basic OAuth information needed to communicate with Twitter
85
+ TWITTER_OAUTH_SPEC = {
86
+ :request_token_path=>'/oauth/request_token',
87
+ :access_token_path=>'/oauth/access_token',
88
+ :authorize_path=>'/oauth/authorize'
89
+ }
90
+
91
+ attr_accessor :auth, :handlers, :default_format, :headers, :ssl, :api, :transport, :request, :api_hosts
92
+
93
+ # Arguments (all are optional):
94
+ # - :username - twitter username to authenticate with (deprecated in favor of :auth arg)
95
+ # - :password - twitter password to authenticate with (deprecated in favor of :auth arg)
96
+ # - :handlers - Hash of formats to Handler instances (e.g. {:json=>CustomJSONHandler.new})
97
+ # - :default_format - Symbol of format to use when no format is specified in an API call (e.g. :json, :xml)
98
+ # - :headers - Hash of string keys and values for headers to pass in the HTTP request to twitter
99
+ # - :ssl - true or false to turn SSL on or off. Default is off (i.e. http://)
100
+ # - :api - one of :rest or :search. :rest is the default
101
+ # - :auth - Hash of authentication type and credentials. Must have :type key with value one of :basic or :oauth
102
+ # - :type=>:basic - Include :username and :password keys
103
+ # - :type=>:oauth - Include :consumer_key, :consumer_secret, :token and :token_secret keys
104
+ def initialize(options={})
105
+ self.transport = Transport.new
106
+ self.handlers = {:json=>Handlers::JSONHandler.new,:xml=>Handlers::XMLHandler.new,:unknown=>Handlers::StringHandler.new}
107
+ self.handlers.merge!(options[:handlers]||{})
108
+ self.default_format = options[:default_format] || :json
109
+ self.headers = {"User-Agent"=>"Grackle/#{Grackle::VERSION}"}.merge!(options[:headers]||{})
110
+ self.ssl = options[:ssl] == true
111
+ self.api = options[:api] || :rest
112
+ self.api_hosts = TWITTER_API_HOSTS.clone
113
+ self.auth = {}
114
+ if options.has_key?(:username) || options.has_key?(:password)
115
+ #Use basic auth if :username and :password args are passed in
116
+ self.auth.merge!({:type=>:basic,:username=>options[:username],:password=>options[:password]})
117
+ end
118
+ #Handle auth mechanism that permits basic or oauth
119
+ if options.has_key?(:auth)
120
+ self.auth = options[:auth]
121
+ if auth[:type] == :oauth
122
+ self.auth = TWITTER_OAUTH_SPEC.merge(auth)
123
+ end
124
+ end
125
+ end
126
+
127
+ def method_missing(name,*args)
128
+
129
+ # if we only have a single String arg,
130
+ # assume it's a query string (as in next_page on Search)
131
+ # and parse it out
132
+ if args[0].is_a?(String) && args.size == 1
133
+ *args = parse_query(args[0][1..-1])
134
+ end
135
+
136
+ #If method is a format name, execute using that format
137
+ if format_invocation?(name)
138
+ return call_with_format(name,*args)
139
+ end
140
+ #If method ends in ! or ? use that to determine post or get
141
+ if name.to_s =~ /^(.*)(!|\?)$/
142
+ name = $1.to_sym
143
+ #! is a post, ? is a get
144
+ self.request.method = ($2 == '!' ? :post : :get)
145
+ if format_invocation?(name)
146
+ return call_with_format(name,*args)
147
+ else
148
+ self.request << "/#{$1}"
149
+ return call_with_format(self.default_format,*args)
150
+ end
151
+ end
152
+ #Else add to the request path
153
+ self.request << "/#{name}"
154
+ self
155
+ end
156
+
157
+ # Used to toggle APIs for a particular request without setting the Client's default API
158
+ # client[:rest].users.show.hayesdavis?
159
+ def [](api_name)
160
+ request.api = api_name
161
+ self
162
+ end
163
+
164
+ #Clears any pending request built up by chained methods but not executed
165
+ def clear
166
+ self.request = nil
167
+ end
168
+
169
+ #Deprecated in favor of using the auth attribute.
170
+ def username
171
+ if auth[:type] == :basic
172
+ auth[:username]
173
+ end
174
+ end
175
+
176
+ #Deprecated in favor of using the auth attribute.
177
+ def username=(value)
178
+ unless auth[:type] == :basic
179
+ auth[:type] = :basic
180
+ end
181
+ auth[:username] = value
182
+ end
183
+
184
+ #Deprecated in favor of using the auth attribute.
185
+ def password
186
+ if auth[:type] == :basic
187
+ auth[:password]
188
+ end
189
+ end
190
+
191
+ #Deprecated in favor of using the auth attribute.
192
+ def password=(value)
193
+ unless auth[:type] == :basic
194
+ auth[:type] = :basic
195
+ end
196
+ auth[:password] = value
197
+ end
198
+
199
+ protected
200
+ def call_with_format(format,params={})
201
+ id = params.delete(:id)
202
+ request << "/#{id}" if id
203
+ request << ".#{format}"
204
+ res = send_request(params)
205
+ process_response(format,res)
206
+ ensure
207
+ clear
208
+ end
209
+
210
+ def send_request(params)
211
+ begin
212
+ transport.request(
213
+ request.method,request.url,:auth=>auth,:headers=>headers,:params=>params
214
+ )
215
+ rescue => e
216
+ puts e
217
+ raise TwitterError.new(request.method,request.url,nil,nil,"Unexpected failure making request: #{e}")
218
+ end
219
+ end
220
+
221
+ def process_response(format,res)
222
+ fmt_handler = handler(format)
223
+ begin
224
+ unless res.status == 200
225
+ handle_error_response(res,fmt_handler)
226
+ else
227
+ fmt_handler.decode_response(res.body)
228
+ end
229
+ rescue TwitterError => e
230
+ raise e
231
+ rescue => e
232
+ raise TwitterError.new(res.method,res.request_uri,res.status,res.body,"Unable to decode response: #{e}")
233
+ end
234
+ end
235
+
236
+ def request
237
+ @request ||= Request.new(self,api,ssl)
238
+ end
239
+
240
+ def handler(format)
241
+ handlers[format] || handlers[:unknown]
242
+ end
243
+
244
+ def handle_error_response(res,handler)
245
+ err = TwitterError.new(res.method,res.request_uri,res.status,res.body)
246
+ err.response_object = handler.decode_response(err.response_body)
247
+ raise err
248
+ end
249
+
250
+ def format_invocation?(name)
251
+ self.request.path? && VALID_FORMATS.include?(name)
252
+ end
253
+
254
+ def parse_query(query)
255
+ params = Hash.new([].freeze)
256
+
257
+ query.split(/[&;]/n).each do |pairs|
258
+ key, value = pairs.split('=',2).collect{|v| CGI::unescape(v) }
259
+ if params.has_key?(key)
260
+ params[key].push(value)
261
+ else
262
+ params[key] = [value]
263
+ end
264
+ end
265
+
266
+ params
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,90 @@
1
+ module Grackle
2
+
3
+ # This module contain handlers that know how to take a response body
4
+ # from Twitter and turn it into a TwitterStruct return value. Handlers are
5
+ # used by the Client to give back return values from API calls. A handler
6
+ # is intended to provide a +decode_response+ method which accepts the response body
7
+ # as a string.
8
+ module Handlers
9
+
10
+ # Decodes JSON Twitter API responses
11
+ class JSONHandler
12
+
13
+ def decode_response(res)
14
+ json_result = JSON.parse(res)
15
+ load_recursive(json_result)
16
+ end
17
+
18
+ private
19
+ def load_recursive(value)
20
+ if value.kind_of? Hash
21
+ build_struct(value)
22
+ elsif value.kind_of? Array
23
+ value.map{|v| load_recursive(v)}
24
+ else
25
+ value
26
+ end
27
+ end
28
+
29
+ def build_struct(hash)
30
+ struct = TwitterStruct.new
31
+ hash.each do |key,v|
32
+ struct.send("#{key}=",load_recursive(v))
33
+ end
34
+ struct
35
+ end
36
+
37
+ end
38
+
39
+ # Decodes XML Twitter API responses
40
+ class XMLHandler
41
+
42
+ #Known nodes returned by twitter that contain arrays
43
+ ARRAY_NODES = ['ids','statuses','users']
44
+
45
+ def decode_response(res)
46
+ xml = REXML::Document.new(res)
47
+ load_recursive(xml.root)
48
+ end
49
+
50
+ private
51
+ def load_recursive(node)
52
+ if array_node?(node)
53
+ node.elements.map {|e| load_recursive(e)}
54
+ elsif node.elements.size > 0
55
+ build_struct(node)
56
+ elsif node.elements.size == 0
57
+ value = node.text
58
+ fixnum?(value) ? value.to_i : value
59
+ end
60
+ end
61
+
62
+ def build_struct(node)
63
+ ts = TwitterStruct.new
64
+ node.elements.each do |e|
65
+ ts.send("#{e.name}=",load_recursive(e))
66
+ end
67
+ ts
68
+ end
69
+
70
+ # Most of the time Twitter specifies nodes that contain an array of
71
+ # sub-nodes with a type="array" attribute. There are some nodes that
72
+ # they dont' do that for, though, including the <ids> node returned
73
+ # by the social graph methods. This method tries to work in both situations.
74
+ def array_node?(node)
75
+ node.attributes['type'] == 'array' || ARRAY_NODES.include?(node.name)
76
+ end
77
+
78
+ def fixnum?(value)
79
+ value =~ /^\d+$/
80
+ end
81
+ end
82
+
83
+ # Just echoes back the response body. This is primarily used for unknown formats
84
+ class StringHandler
85
+ def decode_response(res)
86
+ res
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,193 @@
1
+ module Grackle
2
+
3
+ class Response #:nodoc:
4
+ attr_accessor :method, :request_uri, :status, :body
5
+
6
+ def initialize(method,request_uri,status,body)
7
+ self.method = method
8
+ self.request_uri = request_uri
9
+ self.status = status
10
+ self.body = body
11
+ end
12
+ end
13
+
14
+ class Transport
15
+
16
+ attr_accessor :debug
17
+
18
+ CRLF = "\r\n"
19
+
20
+ def req_class(method)
21
+ case method
22
+ when :get then Net::HTTP::Get
23
+ when :post then Net::HTTP::Post
24
+ when :put then Net::HTTP::Put
25
+ when :delete then Net::HTTP::Delete
26
+ end
27
+ end
28
+
29
+ # Options are one of
30
+ # - :params - a hash of parameters to be sent with the request. If a File is a parameter value, \
31
+ # a multipart request will be sent. If a Time is included, .httpdate will be called on it.
32
+ # - :headers - a hash of headers to send with the request
33
+ # - :auth - a hash of authentication parameters for either basic or oauth
34
+ def request(method, string_url, options={})
35
+ params = stringify_params(options[:params])
36
+ if method == :get && params
37
+ string_url << query_string(params)
38
+ end
39
+ url = URI.parse(string_url)
40
+ begin
41
+ execute_request(method,url,options)
42
+ rescue Timeout::Error
43
+ raise "Timeout while #{method}ing #{url.to_s}"
44
+ end
45
+ end
46
+
47
+ def execute_request(method,url,options={})
48
+ conn = Net::HTTP.new(url.host, url.port)
49
+ conn.use_ssl = (url.scheme == 'https')
50
+ conn.start do |http|
51
+ req = req_class(method).new(url.request_uri)
52
+ add_headers(req,options[:headers])
53
+ if file_param?(options[:params])
54
+ add_multipart_data(req,options[:params])
55
+ else
56
+ add_form_data(req,options[:params])
57
+ end
58
+ if options.has_key? :auth
59
+ if options[:auth][:type] == :basic
60
+ add_basic_auth(req,options[:auth])
61
+ elsif options[:auth][:type] == :oauth
62
+ add_oauth(http,req,options[:auth])
63
+ end
64
+ end
65
+ dump_request(req) if debug
66
+ res = http.request(req)
67
+ dump_response(res) if debug
68
+ Response.new(method,url.to_s,res.code.to_i,res.body)
69
+ end
70
+ end
71
+
72
+ def query_string(params)
73
+ query = case params
74
+ when Hash then params.map{|key,value| url_encode_param(key,value) }.join("&")
75
+ else url_encode(params.to_s)
76
+ end
77
+ if !(query == nil || query.length == 0) && query[0,1] != '?'
78
+ query = "?#{query}"
79
+ end
80
+ query
81
+ end
82
+
83
+ private
84
+ def stringify_params(params)
85
+ return nil unless params
86
+ params.inject({}) do |h, pair|
87
+ key, value = pair
88
+ if value.respond_to? :httpdate
89
+ value = value.httpdate
90
+ end
91
+ h[key] = value
92
+ h
93
+ end
94
+ end
95
+
96
+ def file_param?(params)
97
+ return false unless params
98
+ params.any? {|key,value| value.respond_to? :read }
99
+ end
100
+
101
+ def url_encode(value)
102
+ require 'cgi' unless defined?(CGI) && defined?(CGI::escape)
103
+ CGI.escape(value.to_s)
104
+ end
105
+
106
+ def url_encode_param(key,value)
107
+ "#{url_encode(key)}=#{url_encode(value)}"
108
+ end
109
+
110
+ def add_headers(req,headers)
111
+ if headers
112
+ headers.each do |header, value|
113
+ req[header] = value
114
+ end
115
+ end
116
+ end
117
+
118
+ def add_form_data(req,params)
119
+ if req.request_body_permitted? && params
120
+ req.set_form_data(params)
121
+ end
122
+ end
123
+
124
+ def add_multipart_data(req,params)
125
+ boundary = Time.now.to_i.to_s(16)
126
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
127
+ body = ""
128
+ params.each do |key,value|
129
+ esc_key = url_encode(key)
130
+ body << "--#{boundary}#{CRLF}"
131
+ if value.respond_to?(:read)
132
+ mime_type = MIME::Types.type_for(value.path)[0] || MIME::Types["application/octet-stream"][0]
133
+ body << "Content-Disposition: form-data; name=\"#{esc_key}\"; filename=\"#{File.basename(value.path)}\"#{CRLF}"
134
+ body << "Content-Type: #{mime_type.simplified}#{CRLF*2}"
135
+ body << value.read
136
+ else
137
+ body << "Content-Disposition: form-data; name=\"#{esc_key}\"#{CRLF*2}#{value}"
138
+ end
139
+ body << CRLF
140
+ end
141
+ body << "--#{boundary}--#{CRLF*2}"
142
+ req.body = body
143
+ req["Content-Length"] = req.body.size
144
+ end
145
+
146
+ def add_basic_auth(req,auth)
147
+ username = auth[:username]
148
+ password = auth[:password]
149
+ if username && password
150
+ req.basic_auth(username,password)
151
+ end
152
+ end
153
+
154
+ def add_oauth(conn,req,auth)
155
+ options = auth.reject do |key,value|
156
+ [:type,:consumer_key,:consumer_secret,:token,:token_secret].include?(key)
157
+ end
158
+ unless options.has_key?(:site)
159
+ options[:site] = oauth_site(conn,req)
160
+ end
161
+ consumer = OAuth::Consumer.new(auth[:consumer_key],auth[:consumer_secret],options)
162
+ access_token = OAuth::AccessToken.new(consumer,auth[:token],auth[:token_secret])
163
+ consumer.sign!(req,access_token)
164
+ end
165
+
166
+ private
167
+ def oauth_site(conn,req)
168
+ site = "#{(conn.use_ssl? ? "https" : "http")}://#{conn.address}"
169
+ if (conn.use_ssl? && conn.port != 443) || (!conn.use_ssl? && conn.port != 80)
170
+ site << ":#{conn.port}"
171
+ end
172
+ site
173
+ end
174
+
175
+ def dump_request(req)
176
+ puts "Sending Request"
177
+ puts"#{req.method} #{req.path}"
178
+ dump_headers(req)
179
+ end
180
+
181
+ def dump_response(res)
182
+ puts "Received Response"
183
+ dump_headers(res)
184
+ puts res.body
185
+ end
186
+
187
+ def dump_headers(msg)
188
+ msg.each_header do |key, value|
189
+ puts "\t#{key}=#{value}"
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,16 @@
1
+ module Grackle
2
+ module Utils
3
+
4
+ VALID_PROFILE_IMAGE_SIZES = [:bigger,:normal,:mini]
5
+
6
+ #Easy method for getting different sized profile images using Twitter's naming scheme
7
+ def profile_image_url(url,size=:normal)
8
+ size = VALID_PROFILE_IMAGE_SIZES.find(:normal){|s| s == size.to_sym}
9
+ return url if url.nil? || size == :normal
10
+ url.sub(/_normal\./,"_#{size.to_s}.")
11
+ end
12
+
13
+ module_function :profile_image_url
14
+
15
+ end
16
+ end
data/lib/grackle.rb ADDED
@@ -0,0 +1,31 @@
1
+ module Grackle
2
+
3
+ # :stopdoc:
4
+ VERSION = '0.1.2'
5
+ LIBPATH = ::File.expand_path(::File.dirname(__FILE__)) + ::File::SEPARATOR
6
+ PATH = ::File.dirname(LIBPATH) + ::File::SEPARATOR
7
+ # :startdoc:
8
+
9
+ # Returns the version string for the library.
10
+ def self.version
11
+ VERSION
12
+ end
13
+
14
+ end # module Grackle
15
+
16
+ $:.unshift File.dirname(__FILE__)
17
+
18
+ require 'ostruct'
19
+ require 'open-uri'
20
+ require 'net/http'
21
+ require 'time'
22
+ require 'rexml/document'
23
+ require 'json'
24
+ require 'oauth'
25
+ require 'oauth/client'
26
+ require 'mime/types'
27
+
28
+ require 'grackle/utils'
29
+ require 'grackle/transport'
30
+ require 'grackle/handlers'
31
+ require 'grackle/client'
@@ -0,0 +1,171 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ class TestClient < Test::Unit::TestCase
4
+
5
+ #Used for mocking HTTP requests
6
+ class Net::HTTP
7
+ class << self
8
+ attr_accessor :response, :request, :last_instance
9
+ end
10
+
11
+ def request(req)
12
+ self.class.last_instance = self
13
+ self.class.request = req
14
+ self.class.response
15
+ end
16
+ end
17
+
18
+ #Mock responses that conform mostly to HTTPResponse's interface
19
+ class MockResponse
20
+ include Net::HTTPHeader
21
+ attr_accessor :code, :body
22
+ def initialize(code,body,headers={})
23
+ self.code = code
24
+ self.body = body
25
+ headers.each do |name, value|
26
+ self[name] = value
27
+ end
28
+ end
29
+ end
30
+
31
+ #Transport that collects info on requests and responses for testing purposes
32
+ class MockTransport < Grackle::Transport
33
+ attr_accessor :status, :body, :method, :url, :options
34
+
35
+ def initialize(status,body,headers={})
36
+ Net::HTTP.response = MockResponse.new(status,body,headers)
37
+ end
38
+
39
+ def request(method, string_url, options)
40
+ self.method = method
41
+ self.url = URI.parse(string_url)
42
+ self.options = options
43
+ super(method,string_url,options)
44
+ end
45
+ end
46
+
47
+ class TestHandler
48
+ attr_accessor :decode_value
49
+
50
+ def initialize(value)
51
+ self.decode_value = value
52
+ end
53
+
54
+ def decode_response(body)
55
+ decode_value
56
+ end
57
+ end
58
+
59
+ def test_simple_get_request
60
+ client = new_client(200,'{"id":12345,"screen_name":"test_user"}')
61
+ value = client.users.show.json? :screen_name=>'test_user'
62
+ assert_equal(:get,client.transport.method)
63
+ assert_equal('http',client.transport.url.scheme)
64
+ assert(!Net::HTTP.last_instance.use_ssl?,'Net::HTTP instance should not be set to use SSL')
65
+ assert_equal('twitter.com',client.transport.url.host)
66
+ assert_equal('/users/show.json',client.transport.url.path)
67
+ assert_equal('test_user',client.transport.options[:params][:screen_name])
68
+ assert_equal('screen_name=test_user',Net::HTTP.request.path.split(/\?/)[1])
69
+ assert_equal(12345,value.id)
70
+ end
71
+
72
+ def test_simple_post_request_with_basic_auth
73
+ client = Grackle::Client.new(:auth=>{:type=>:basic,:username=>'fake_user',:password=>'fake_pass'})
74
+ test_simple_post(client) do
75
+ assert_match(/Basic/i,Net::HTTP.request['Authorization'],"Request should include Authorization header for basic auth")
76
+ end
77
+ end
78
+
79
+ def test_simple_post_request_with_oauth
80
+ client = Grackle::Client.new(:auth=>{:type=>:oauth,:consumer_key=>'12345',:consumer_secret=>'abc',:token=>'wxyz',:token_secret=>'98765'})
81
+ test_simple_post(client) do
82
+ auth = Net::HTTP.request['Authorization']
83
+ assert_match(/OAuth/i,auth,"Request should include Authorization header for OAuth")
84
+ assert_match(/oauth_consumer_key="12345"/,auth,"Auth header should include consumer key")
85
+ assert_match(/oauth_token="wxyz"/,auth,"Auth header should include token")
86
+ assert_match(/oauth_signature_method="HMAC-SHA1"/,auth,"Auth header should include HMAC-SHA1 signature method as that's what Twitter supports")
87
+ end
88
+ end
89
+
90
+ def test_ssl
91
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:ssl=>true)
92
+ client.statuses.public_timeline?
93
+ assert_equal("https",client.transport.url.scheme)
94
+ assert(Net::HTTP.last_instance.use_ssl?,'Net::HTTP instance should be set to use SSL')
95
+ end
96
+
97
+ def test_default_format
98
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:default_format=>:json)
99
+ client.statuses.public_timeline?
100
+ assert_match(/\.json$/,client.transport.url.path)
101
+
102
+ client = new_client(200,'<statuses type="array"><status><id>1</id><text>test 1</text></status></statuses>',:default_format=>:xml)
103
+ client.statuses.public_timeline?
104
+ assert_match(/\.xml$/,client.transport.url.path)
105
+ end
106
+
107
+ def test_api
108
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:api=>:search)
109
+ client.search? :q=>'test'
110
+ assert_equal('search.twitter.com',client.transport.url.host)
111
+ client[:rest].users.show.some_user?
112
+ assert_equal('twitter.com',client.transport.url.host)
113
+ client.api = :search
114
+ client.trends?
115
+ assert_equal('search.twitter.com',client.transport.url.host)
116
+ end
117
+
118
+ def test_headers
119
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:headers=>{'User-Agent'=>'TestAgent/1.0','X-Test-Header'=>'Header Value'})
120
+ client.statuses.public_timeline?
121
+ assert_equal('TestAgent/1.0',Net::HTTP.request['User-Agent'],"Custom User-Agent header should have been set")
122
+ assert_equal('Header Value',Net::HTTP.request['X-Test-Header'],"Custom X-Test-Header header should have been set")
123
+ end
124
+
125
+ def test_custom_handlers
126
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:handlers=>{:json=>TestHandler.new(42)})
127
+ value = client.statuses.public_timeline.json?
128
+ assert_equal(42,value)
129
+ end
130
+
131
+ def test_clear
132
+ client = new_client(200,'[{"id":1,"text":"test 1"}]')
133
+ client.some.url.that.does.not.exist
134
+ assert_equal('/some/url/that/does/not/exist',client.send(:request).path,"An unexecuted path should be build up")
135
+ client.clear
136
+ assert_equal('',client.send(:request).path,"The path shoudl be cleared")
137
+ end
138
+
139
+ def test_file_param_triggers_multipart_encoding
140
+ client = new_client(200,'[{"id":1,"text":"test 1"}]')
141
+ client.account.update_profile_image! :image=>File.new(__FILE__)
142
+ assert_match(/multipart\/form-data/,Net::HTTP.request['Content-Type'])
143
+ end
144
+
145
+ def test_time_param_is_http_encoded_and_escaped
146
+ client = new_client(200,'[{"id":1,"text":"test 1"}]')
147
+ time = Time.now-60*60
148
+ client.statuses.public_timeline? :since=>time
149
+ assert_equal("/statuses/public_timeline.json?since=#{CGI::escape(time.httpdate)}",Net::HTTP.request.path)
150
+ end
151
+
152
+ private
153
+ def new_client(response_status, response_body, client_opts={})
154
+ client = Grackle::Client.new(client_opts)
155
+ client.transport = MockTransport.new(response_status,response_body)
156
+ client
157
+ end
158
+
159
+ def test_simple_post(client)
160
+ client.transport = MockTransport.new(200,'{"id":12345,"text":"test status"}')
161
+ value = client.statuses.update! :status=>'test status'
162
+ assert_equal(:post,client.transport.method,"Expected post request")
163
+ assert_equal('http',client.transport.url.scheme,"Expected scheme to be http")
164
+ assert_equal('twitter.com',client.transport.url.host,"Expected request to be against twitter.com")
165
+ assert_equal('/statuses/update.json',client.transport.url.path)
166
+ assert_match(/status=test%20status/,Net::HTTP.request.body,"Parameters should be form encoded")
167
+ assert_equal(12345,value.id)
168
+ yield(client) if block_given?
169
+ end
170
+
171
+ end
@@ -0,0 +1,4 @@
1
+ Dir.glob("#{File.dirname(__FILE__)}/*.rb").each do |file|
2
+ require file
3
+ end
4
+
@@ -0,0 +1,89 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ class TestHandlers < Test::Unit::TestCase
4
+
5
+ def test_string_handler_echoes
6
+ sh = Grackle::Handlers::StringHandler.new
7
+ body = "This is some text"
8
+ assert_equal(body,sh.decode_response(body),"String handler should just echo response body")
9
+ end
10
+
11
+ def test_xml_handler_parses_text_only_nodes_as_attributes
12
+ h = Grackle::Handlers::XMLHandler.new
13
+ body = "<user><id>12345</id><screen_name>User1</screen_name></user>"
14
+ value = h.decode_response(body)
15
+ assert_equal(12345,value.id,"Id element should be treated as an attribute and be returned as a Fixnum")
16
+ assert_equal("User1",value.screen_name,"screen_name element should be treated as an attribute")
17
+ end
18
+
19
+ def test_xml_handler_parses_nested_elements_with_children_as_nested_objects
20
+ h = Grackle::Handlers::XMLHandler.new
21
+ body = "<user><id>12345</id><screen_name>User1</screen_name><status><id>9876</id><text>this is a status</text></status></user>"
22
+ value = h.decode_response(body)
23
+ assert_not_nil(value.status,"status element should be turned into an object")
24
+ assert_equal(9876,value.status.id,"status element should have id")
25
+ assert_equal("this is a status",value.status.text,"status element should have text")
26
+ end
27
+
28
+ def test_xml_handler_parses_elements_with_type_array_as_arrays
29
+ h = Grackle::Handlers::XMLHandler.new
30
+ body = "<some_ids type=\"array\">"
31
+ 1.upto(10) do |i|
32
+ body << "<id>#{i}</id>"
33
+ end
34
+ body << "</some_ids>"
35
+ value = h.decode_response(body)
36
+ assert_equal(Array,value.class,"Root parsed object should be an array")
37
+ assert_equal(10,value.length,"Parsed array should have correct length")
38
+ 0.upto(9) do |i|
39
+ assert_equal(i+1,value[i],"Parsed array should contain #{i+1} at index #{i}")
40
+ end
41
+ end
42
+
43
+ def test_xml_handler_parses_certain_elements_as_arrays
44
+ h = Grackle::Handlers::XMLHandler.new
45
+ special_twitter_elements = ['ids','statuses','users']
46
+ special_twitter_elements.each do |name|
47
+ body = "<#{name}>"
48
+ 1.upto(10) do |i|
49
+ body << "<complex_value><id>#{i}</id><profile>This is profile #{i}</profile></complex_value>"
50
+ end
51
+ body << "</#{name}>"
52
+ value = h.decode_response(body)
53
+ assert_equal(Array,value.class,"Root parsed object should be an array")
54
+ assert_equal(10,value.length,"Parsed array should have correct length")
55
+ 0.upto(9) do |i|
56
+ assert_equal(i+1,value[i].id,"Parsed array should contain id #{i+1} at index #{i}")
57
+ assert_equal("This is profile #{i+1}",value[i].profile,"Parsed array should contain profile 'This is profile #{i+1}' at index #{i}")
58
+ end
59
+ end
60
+ end
61
+
62
+ def test_json_handler_parses_basic_attributes
63
+ h = Grackle::Handlers::JSONHandler.new
64
+ body = '{"id":12345,"screen_name":"User1"}'
65
+ value = h.decode_response(body)
66
+ assert_equal(12345,value.id,"Id element should be treated as an attribute and be returned as a Fixnum")
67
+ assert_equal("User1",value.screen_name,"screen_name element should be treated as an attribute")
68
+ end
69
+
70
+ def test_json_handler_parses_complex_attributes
71
+ h = Grackle::Handlers::JSONHandler.new
72
+ body = '{"id":12345,"screen_name":"User1","statuses":['
73
+ 1.upto(10) do |i|
74
+ user_id = i+5000
75
+ body << ',' unless i == 1
76
+ body << %Q{{"id":#{i},"text":"Status from user #{user_id}", "user":{"id":#{user_id},"screen_name":"User #{user_id}"}}}
77
+ end
78
+ body << ']}'
79
+ value = h.decode_response(body)
80
+ assert_equal(12345,value.id,"Id element should be treated as an attribute and be returned as a Fixnum")
81
+ assert_equal("User1",value.screen_name,"screen_name element should be treated as an attribute")
82
+ assert_equal(Array,value.statuses.class,"statuses attribute should be an array")
83
+ 1.upto(10) do |i|
84
+ assert_equal(i,value.statuses[i-1].id,"array should contain status with id #{i} at index #{i-1}")
85
+ assert_equal(i+5000,value.statuses[i-1].user.id,"status at index #{i-1} should contain user with id #{i+5000}")
86
+ end
87
+ end
88
+
89
+ end
@@ -0,0 +1,3 @@
1
+ require 'test/unit'
2
+ require 'rubygems'
3
+ require File.dirname(__FILE__) + '/../lib/grackle'
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: joncanady-grackle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ platform: ruby
6
+ authors:
7
+ - Hayes Davis
8
+ - Jon Canady
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2009-07-22 00:00:00 -07:00
14
+ default_executable:
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: json
18
+ type: :runtime
19
+ version_requirement:
20
+ version_requirements: !ruby/object:Gem::Requirement
21
+ requirements:
22
+ - - ">="
23
+ - !ruby/object:Gem::Version
24
+ version: "0"
25
+ version:
26
+ - !ruby/object:Gem::Dependency
27
+ name: mime-types
28
+ type: :runtime
29
+ version_requirement:
30
+ version_requirements: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: "0"
35
+ version:
36
+ - !ruby/object:Gem::Dependency
37
+ name: oauth
38
+ type: :runtime
39
+ version_requirement:
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: "0"
45
+ version:
46
+ description: Grackle is a lightweight library for the Twitter REST and Search API.
47
+ email: jon@joncanady.com
48
+ executables: []
49
+
50
+ extensions: []
51
+
52
+ extra_rdoc_files:
53
+ - README.rdoc
54
+ files:
55
+ - History.txt
56
+ - README.rdoc
57
+ - grackle.gemspec
58
+ - lib/grackle.rb
59
+ - lib/grackle/client.rb
60
+ - lib/grackle/handlers.rb
61
+ - lib/grackle/transport.rb
62
+ - lib/grackle/utils.rb
63
+ - test/test_grackle.rb
64
+ - test/test_helper.rb
65
+ - test/test_client.rb
66
+ - test/test_handlers.rb
67
+ has_rdoc: true
68
+ homepage: http://github.com/joncanady/grackle
69
+ post_install_message:
70
+ rdoc_options:
71
+ - --inline-source
72
+ - --charset=UTF-8
73
+ - --main=README.rdoc
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: "0"
81
+ version:
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: "0"
87
+ version:
88
+ requirements: []
89
+
90
+ rubyforge_project: grackle
91
+ rubygems_version: 1.2.0
92
+ signing_key:
93
+ specification_version: 2
94
+ summary: Grackle is a library for the Twitter REST and Search API designed to not require a new release in the face Twitter API changes or errors. It supports both basic and OAuth authentication mechanisms.
95
+ test_files: []
96
+