ben5516-grackle 0.1.2

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,155 @@
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
+ This is Grackle for Rails, which fixes a persistent JSON issue with Rails.
23
+
24
+ ==USING GRACKLE
25
+
26
+ Before you do anything else, you'll need to
27
+ require 'grackle'
28
+
29
+ ===Creating a Grackle::Client
30
+ ====Using Basic Auth
31
+ client = Grackle::Client.new(:auth=>{:type=>:basic,:username=>'your_user',:password=>'yourpass'})
32
+
33
+ ====Using OAuth
34
+ client = Grackle::Client.new(:auth=>{
35
+ :type=>:oauth,
36
+ :consumer_key=>'SOMECONSUMERKEYFROMTWITTER', :consumer_secret=>'SOMECONSUMERTOKENFROMTWITTER',
37
+ :token=>'ACCESSTOKENACQUIREDONUSERSBEHALF', :token_secret=>'SUPERSECRETACCESSTOKENSECRET'
38
+ })
39
+
40
+ ====Using No Auth
41
+ client = Grackle::Client.new
42
+
43
+ See Grackle::Client for more information about valid arguments to the constructor. It's quite configurable. Among other things,
44
+ you can turn on ssl and specify custom headers. The calls below are pretty much as simple as it gets.
45
+
46
+ ===Grackle Method Syntax
47
+ Grackle uses a method syntax that corresponds to the Twitter API URLs with a few twists. Where you would have a slash in
48
+ a Twitter URL, that becomes a "." in a chained set of Grackle method calls. Each call in the method chain is used to build
49
+ Twitter URL path until a particular call is encountered which causes the request to be sent. Methods which will cause a
50
+ request to be execute include:
51
+ - A method call ending in "?" will cause an HTTP GET to be executed
52
+ - A method call ending in "!" will cause an HTTP POST to be executed
53
+ - If a valid format such as .json, .xml, .rss or .atom is encounted, a get will be executed with that format
54
+ - A format method can also include a ? or ! to determine GET or POST in that format respectively
55
+
56
+ ===GETting Data
57
+ The preferred and simplest way of executing a GET is to use the "?" method notation. This will use the default client
58
+ format (usually JSON, but see Formats section below):
59
+ client.users.show? :screen_name=>'some_user' #http://twitter.com/users/show.json?screen_name=some_user
60
+
61
+ You can force XML format by doing:
62
+ client.users.show.xml? :screen_name=>'some_user' #http://twitter.com/users/show.xml?scren_name=some_user
63
+
64
+ You can force JSON:
65
+ client.users.show.json? :screen_name=>'some_user' #http://twitter.com/users/show.json?screen_name=some_user
66
+
67
+ Or, since Twitter also allows certain ids/screen_names to be part of their URLs, this works:
68
+ client.users.show.some_user? #http://twitter.com/users/show/some_user.json
69
+
70
+ If you use an explicit format, you can leave off the "?" like so:
71
+ client.users.show.xml :screen_name=>'some_user' #http://twitter.com/users/show.xml?scren_name=some_user
72
+
73
+ ===POSTing data
74
+ To use Twitter API methods that require an HTTP POST, you need to end your method chain with a bang (!)
75
+
76
+ The preferred way is to use the Client's default format (usually JSON, but see Formats section below):
77
+ client.statuses.update! :status=>'this status is from grackle' #POST to http://twitter.com/statuses/update.json
78
+
79
+ You can force a format. To update the authenticated user's status using the XML format:
80
+ client.statuses.update.xml! :status=>'this status is from grackle' #POST to http://twitter.com/statuses/update.xml
81
+
82
+ Or, with JSON
83
+ client.statuses.update.json! :status=>'this status is from grackle' #POST to http://twitter.com/statuses/update.json
84
+
85
+ ===Toggling APIs
86
+ 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
87
+ set Grackle::Client.api to :search. To toggle back, set it to be :rest. All requests made after setting this
88
+ attribute will go to that API.
89
+
90
+ 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
91
+ bracket syntax like so:
92
+ client[:search].trends.daily? :exclude=>'hashtags'
93
+ client[:rest].users.show? :id=>'hayesdavis'
94
+
95
+ Search and REST requests are all built using the same method chaining and termination conventions.
96
+
97
+ ===Parameter handling
98
+ - All parameters are URL encoded as necessary.
99
+ - If you use a File object as a parameter it will be POSTed to Twitter in a multipart request.
100
+ - If you use a Time object as a parameter, .httpdate will be called on it and that value will be used
101
+
102
+ ===Return Values
103
+ Regardless of the format used, Grackle returns an OpenStruct (actually a Grackle::TwitterStruct) of data. The attributes
104
+ available on these structs correspond to the data returned by Twitter.
105
+
106
+ ===Dealing with Errors
107
+ If the request to Twitter does not return a status code of 200, then a TwitterError is thrown. This contains the HTTP method used,
108
+ the full request URI, the response status, the response body in text and a response object build by parsing the formatted error
109
+ returned by Twitter. It's a good idea to wrap your API calls with rescue clauses for Grackle::TwitterError.
110
+
111
+ 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.
112
+
113
+ ===Formats
114
+ Twitter allows you to request data in particular formats. Grackle automatically parses JSON and XML formatted responses
115
+ and returns an OpenStruct. If you specify a format that Grackle doesn't parse for you, you'll receive a string containing
116
+ the raw response body. The Grackle::Client has a default_format you can specify. By default, the default_format is :json.
117
+ If you don't include a named format in your method chain as described above, but use a "?" or "!" then the
118
+ Grackle::Client.default_format is used.
119
+
120
+ == REQUIREMENTS:
121
+
122
+ You'll need the following gems to use all features of Grackle:
123
+ - json
124
+ - oauth
125
+ - mime-types
126
+
127
+ == INSTALL:
128
+
129
+ sudo gem sources -a http://gems.github.com
130
+ sudo gem install hayesdavis-grackle
131
+
132
+ == LICENSE:
133
+
134
+ (The MIT License)
135
+
136
+ Copyright (c) 2009
137
+
138
+ Permission is hereby granted, free of charge, to any person obtaining
139
+ a copy of this software and associated documentation files (the
140
+ 'Software'), to deal in the Software without restriction, including
141
+ without limitation the rights to use, copy, modify, merge, publish,
142
+ distribute, sublicense, and/or sell copies of the Software, and to
143
+ permit persons to whom the Software is furnished to do so, subject to
144
+ the following conditions:
145
+
146
+ The above copyright notice and this permission notice shall be
147
+ included in all copies or substantial portions of the Software.
148
+
149
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
150
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
151
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
152
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
153
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
154
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
155
+ 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.2"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["Hayes Davis"]
9
+ s.date = %q{2009-04-12}
10
+ s.description = %q{Grackle is a lightweight library for the Twitter REST and Search API.}
11
+ s.email = %q{hayes@appozite.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/hayesdavis/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.2') 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,246 @@
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
+ #If method is a format name, execute using that format
129
+ if format_invocation?(name)
130
+ return call_with_format(name,*args)
131
+ end
132
+ #If method ends in ! or ? use that to determine post or get
133
+ if name.to_s =~ /^(.*)(!|\?)$/
134
+ name = $1.to_sym
135
+ #! is a post, ? is a get
136
+ self.request.method = ($2 == '!' ? :post : :get)
137
+ if format_invocation?(name)
138
+ return call_with_format(name,*args)
139
+ else
140
+ self.request << "/#{$1}"
141
+ return call_with_format(self.default_format,*args)
142
+ end
143
+ end
144
+ #Else add to the request path
145
+ self.request << "/#{name}"
146
+ self
147
+ end
148
+
149
+ # Used to toggle APIs for a particular request without setting the Client's default API
150
+ # client[:rest].users.show.hayesdavis?
151
+ def [](api_name)
152
+ request.api = api_name
153
+ self
154
+ end
155
+
156
+ #Clears any pending request built up by chained methods but not executed
157
+ def clear
158
+ self.request = nil
159
+ end
160
+
161
+ #Deprecated in favor of using the auth attribute.
162
+ def username
163
+ if auth[:type] == :basic
164
+ auth[:username]
165
+ end
166
+ end
167
+
168
+ #Deprecated in favor of using the auth attribute.
169
+ def username=(value)
170
+ unless auth[:type] == :basic
171
+ auth[:type] = :basic
172
+ end
173
+ auth[:username] = value
174
+ end
175
+
176
+ #Deprecated in favor of using the auth attribute.
177
+ def password
178
+ if auth[:type] == :basic
179
+ auth[:password]
180
+ end
181
+ end
182
+
183
+ #Deprecated in favor of using the auth attribute.
184
+ def password=(value)
185
+ unless auth[:type] == :basic
186
+ auth[:type] = :basic
187
+ end
188
+ auth[:password] = value
189
+ end
190
+
191
+ protected
192
+ def call_with_format(format,params={})
193
+ id = params.delete(:id)
194
+ request << "/#{id}" if id
195
+ request << ".#{format}"
196
+ res = send_request(params)
197
+ process_response(format,res)
198
+ ensure
199
+ clear
200
+ end
201
+
202
+ def send_request(params)
203
+ begin
204
+ transport.request(
205
+ request.method,request.url,:auth=>auth,:headers=>headers,:params=>params
206
+ )
207
+ rescue => e
208
+ puts e
209
+ raise TwitterError.new(request.method,request.url,nil,nil,"Unexpected failure making request: #{e}")
210
+ end
211
+ end
212
+
213
+ def process_response(format,res)
214
+ fmt_handler = handler(format)
215
+ begin
216
+ unless res.status == 200
217
+ handle_error_response(res,fmt_handler)
218
+ else
219
+ fmt_handler.decode_response(res.body)
220
+ end
221
+ rescue TwitterError => e
222
+ raise e
223
+ rescue => e
224
+ raise TwitterError.new(res.method,res.request_uri,res.status,res.body,"Unable to decode response: #{e}")
225
+ end
226
+ end
227
+
228
+ def request
229
+ @request ||= Request.new(self,api,ssl)
230
+ end
231
+
232
+ def handler(format)
233
+ handlers[format] || handlers[:unknown]
234
+ end
235
+
236
+ def handle_error_response(res,handler)
237
+ err = TwitterError.new(res.method,res.request_uri,res.status,res.body)
238
+ err.response_object = handler.decode_response(err.response_body)
239
+ raise err
240
+ end
241
+
242
+ def format_invocation?(name)
243
+ self.request.path? && VALID_FORMATS.include?(name)
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,102 @@
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
+ unless Object.const_defined?("ActiveSupport") and ActiveSupport.const_defined?("JSON")
14
+ require 'json'
15
+ def json_decode(str)
16
+ JSON.parse(str)
17
+ end
18
+ else
19
+ def json_decode(str)
20
+ ActiveSupport::JSON.decode(str)
21
+ end
22
+ end
23
+
24
+
25
+ def decode_response(res)
26
+ json_result = json_decode(res)
27
+ load_recursive(json_result)
28
+ end
29
+
30
+ private
31
+ def load_recursive(value)
32
+ if value.kind_of? Hash
33
+ build_struct(value)
34
+ elsif value.kind_of? Array
35
+ value.map{|v| load_recursive(v)}
36
+ else
37
+ value
38
+ end
39
+ end
40
+
41
+ def build_struct(hash)
42
+ struct = TwitterStruct.new
43
+ hash.each do |key,v|
44
+ struct.send("#{key}=",load_recursive(v))
45
+ end
46
+ struct
47
+ end
48
+
49
+ end
50
+
51
+ # Decodes XML Twitter API responses
52
+ class XMLHandler
53
+
54
+ #Known nodes returned by twitter that contain arrays
55
+ ARRAY_NODES = ['ids','statuses','users']
56
+
57
+ def decode_response(res)
58
+ xml = REXML::Document.new(res)
59
+ load_recursive(xml.root)
60
+ end
61
+
62
+ private
63
+ def load_recursive(node)
64
+ if array_node?(node)
65
+ node.elements.map {|e| load_recursive(e)}
66
+ elsif node.elements.size > 0
67
+ build_struct(node)
68
+ elsif node.elements.size == 0
69
+ value = node.text
70
+ fixnum?(value) ? value.to_i : value
71
+ end
72
+ end
73
+
74
+ def build_struct(node)
75
+ ts = TwitterStruct.new
76
+ node.elements.each do |e|
77
+ ts.send("#{e.name}=",load_recursive(e))
78
+ end
79
+ ts
80
+ end
81
+
82
+ # Most of the time Twitter specifies nodes that contain an array of
83
+ # sub-nodes with a type="array" attribute. There are some nodes that
84
+ # they dont' do that for, though, including the <ids> node returned
85
+ # by the social graph methods. This method tries to work in both situations.
86
+ def array_node?(node)
87
+ node.attributes['type'] == 'array' || ARRAY_NODES.include?(node.name)
88
+ end
89
+
90
+ def fixnum?(value)
91
+ value =~ /^\d+$/
92
+ end
93
+ end
94
+
95
+ # Just echoes back the response body. This is primarily used for unknown formats
96
+ class StringHandler
97
+ def decode_response(res)
98
+ res
99
+ end
100
+ end
101
+ end
102
+ 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,30 @@
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 'oauth'
24
+ require 'oauth/client'
25
+ require 'mime/types'
26
+
27
+ require 'grackle/utils'
28
+ require 'grackle/transport'
29
+ require 'grackle/handlers'
30
+ 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,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ben5516-grackle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Hayes Davis
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-04-12 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: json
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: mime-types
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: "0"
34
+ version:
35
+ - !ruby/object:Gem::Dependency
36
+ name: oauth
37
+ type: :runtime
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: "0"
44
+ version:
45
+ description: Grackle is a lightweight library for the Twitter REST and Search API.
46
+ email: hayes@appozite.com
47
+ executables: []
48
+
49
+ extensions: []
50
+
51
+ extra_rdoc_files:
52
+ - README.rdoc
53
+ files:
54
+ - History.txt
55
+ - README.rdoc
56
+ - grackle.gemspec
57
+ - lib/grackle.rb
58
+ - lib/grackle/client.rb
59
+ - lib/grackle/handlers.rb
60
+ - lib/grackle/transport.rb
61
+ - lib/grackle/utils.rb
62
+ - test/test_grackle.rb
63
+ - test/test_helper.rb
64
+ - test/test_client.rb
65
+ - test/test_handlers.rb
66
+ has_rdoc: true
67
+ homepage: http://github.com/hayesdavis/grackle
68
+ post_install_message:
69
+ rdoc_options:
70
+ - --inline-source
71
+ - --charset=UTF-8
72
+ - --main=README.rdoc
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: "0"
80
+ version:
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: "0"
86
+ version:
87
+ requirements: []
88
+
89
+ rubyforge_project: grackle
90
+ rubygems_version: 1.2.0
91
+ signing_key:
92
+ specification_version: 2
93
+ 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.
94
+ test_files: []
95
+