r38y-grackle 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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_token=>'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.
@@ -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"]
9
+ s.date = %q{2009-05-23}
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.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,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,247 @@
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_token=>'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, :timeout
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.timeout = options[:timeout] || 60
114
+ self.auth = {}
115
+ if options.has_key?(:username) || options.has_key?(:password)
116
+ #Use basic auth if :username and :password args are passed in
117
+ self.auth.merge!({:type=>:basic,:username=>options[:username],:password=>options[:password]})
118
+ end
119
+ #Handle auth mechanism that permits basic or oauth
120
+ if options.has_key?(:auth)
121
+ self.auth = options[:auth]
122
+ if auth[:type] == :oauth
123
+ self.auth = TWITTER_OAUTH_SPEC.merge(auth)
124
+ end
125
+ end
126
+ end
127
+
128
+ def method_missing(name,*args)
129
+ #If method is a format name, execute using that format
130
+ if format_invocation?(name)
131
+ return call_with_format(name,*args)
132
+ end
133
+ #If method ends in ! or ? use that to determine post or get
134
+ if name.to_s =~ /^(.*)(!|\?)$/
135
+ name = $1.to_sym
136
+ #! is a post, ? is a get
137
+ self.request.method = ($2 == '!' ? :post : :get)
138
+ if format_invocation?(name)
139
+ return call_with_format(name,*args)
140
+ else
141
+ self.request << "/#{$1}"
142
+ return call_with_format(self.default_format,*args)
143
+ end
144
+ end
145
+ #Else add to the request path
146
+ self.request << "/#{name}"
147
+ self
148
+ end
149
+
150
+ # Used to toggle APIs for a particular request without setting the Client's default API
151
+ # client[:rest].users.show.hayesdavis?
152
+ def [](api_name)
153
+ request.api = api_name
154
+ self
155
+ end
156
+
157
+ #Clears any pending request built up by chained methods but not executed
158
+ def clear
159
+ self.request = nil
160
+ end
161
+
162
+ #Deprecated in favor of using the auth attribute.
163
+ def username
164
+ if auth[:type] == :basic
165
+ auth[:username]
166
+ end
167
+ end
168
+
169
+ #Deprecated in favor of using the auth attribute.
170
+ def username=(value)
171
+ unless auth[:type] == :basic
172
+ auth[:type] = :basic
173
+ end
174
+ auth[:username] = value
175
+ end
176
+
177
+ #Deprecated in favor of using the auth attribute.
178
+ def password
179
+ if auth[:type] == :basic
180
+ auth[:password]
181
+ end
182
+ end
183
+
184
+ #Deprecated in favor of using the auth attribute.
185
+ def password=(value)
186
+ unless auth[:type] == :basic
187
+ auth[:type] = :basic
188
+ end
189
+ auth[:password] = value
190
+ end
191
+
192
+ protected
193
+ def call_with_format(format,params={})
194
+ id = params.delete(:id)
195
+ request << "/#{id}" if id
196
+ request << ".#{format}"
197
+ res = send_request(params)
198
+ process_response(format,res)
199
+ ensure
200
+ clear
201
+ end
202
+
203
+ def send_request(params)
204
+ begin
205
+ transport.request(
206
+ request.method,request.url,:auth=>auth,:headers=>headers,:params=>params, :timeout => timeout
207
+ )
208
+ rescue => e
209
+ puts e
210
+ raise TwitterError.new(request.method,request.url,nil,nil,"Unexpected failure making request: #{e}")
211
+ end
212
+ end
213
+
214
+ def process_response(format,res)
215
+ fmt_handler = handler(format)
216
+ begin
217
+ unless res.status == 200
218
+ handle_error_response(res,fmt_handler)
219
+ else
220
+ fmt_handler.decode_response(res.body)
221
+ end
222
+ rescue TwitterError => e
223
+ raise e
224
+ rescue => e
225
+ raise TwitterError.new(res.method,res.request_uri,res.status,res.body,"Unable to decode response: #{e}")
226
+ end
227
+ end
228
+
229
+ def request
230
+ @request ||= Request.new(self,api,ssl)
231
+ end
232
+
233
+ def handler(format)
234
+ handlers[format] || handlers[:unknown]
235
+ end
236
+
237
+ def handle_error_response(res,handler)
238
+ err = TwitterError.new(res.method,res.request_uri,res.status,res.body)
239
+ err.response_object = handler.decode_response(err.response_body)
240
+ raise err
241
+ end
242
+
243
+ def format_invocation?(name)
244
+ self.request.path? && VALID_FORMATS.include?(name)
245
+ end
246
+ end
247
+ 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,195 @@
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
+ # - :timeout - timeout for the http request in seconds
35
+ def request(method, string_url, options={})
36
+ params = stringify_params(options[:params])
37
+ if method == :get && params
38
+ string_url << query_string(params)
39
+ end
40
+ url = URI.parse(string_url)
41
+ begin
42
+ execute_request(method,url,options)
43
+ rescue Timeout::Error
44
+ raise "Timeout while #{method}ing #{url.to_s}"
45
+ end
46
+ end
47
+
48
+ def execute_request(method,url,options={})
49
+ conn = Net::HTTP.new(url.host, url.port)
50
+ conn.use_ssl = (url.scheme == 'https')
51
+ conn.start do |http|
52
+ req = req_class(method).new(url.request_uri)
53
+ http.read_timeout = options[:timeout]
54
+ add_headers(req,options[:headers])
55
+ if file_param?(options[:params])
56
+ add_multipart_data(req,options[:params])
57
+ else
58
+ add_form_data(req,options[:params])
59
+ end
60
+ if options.has_key? :auth
61
+ if options[:auth][:type] == :basic
62
+ add_basic_auth(req,options[:auth])
63
+ elsif options[:auth][:type] == :oauth
64
+ add_oauth(http,req,options[:auth])
65
+ end
66
+ end
67
+ dump_request(req) if debug
68
+ res = http.request(req)
69
+ dump_response(res) if debug
70
+ Response.new(method,url.to_s,res.code.to_i,res.body)
71
+ end
72
+ end
73
+
74
+ def query_string(params)
75
+ query = case params
76
+ when Hash then params.map{|key,value| url_encode_param(key,value) }.join("&")
77
+ else url_encode(params.to_s)
78
+ end
79
+ if !(query == nil || query.length == 0) && query[0,1] != '?'
80
+ query = "?#{query}"
81
+ end
82
+ query
83
+ end
84
+
85
+ private
86
+ def stringify_params(params)
87
+ return nil unless params
88
+ params.inject({}) do |h, pair|
89
+ key, value = pair
90
+ if value.respond_to? :httpdate
91
+ value = value.httpdate
92
+ end
93
+ h[key] = value
94
+ h
95
+ end
96
+ end
97
+
98
+ def file_param?(params)
99
+ return false unless params
100
+ params.any? {|key,value| value.respond_to? :read }
101
+ end
102
+
103
+ def url_encode(value)
104
+ require 'cgi' unless defined?(CGI) && defined?(CGI::escape)
105
+ CGI.escape(value.to_s)
106
+ end
107
+
108
+ def url_encode_param(key,value)
109
+ "#{url_encode(key)}=#{url_encode(value)}"
110
+ end
111
+
112
+ def add_headers(req,headers)
113
+ if headers
114
+ headers.each do |header, value|
115
+ req[header] = value
116
+ end
117
+ end
118
+ end
119
+
120
+ def add_form_data(req,params)
121
+ if req.request_body_permitted? && params
122
+ req.set_form_data(params)
123
+ end
124
+ end
125
+
126
+ def add_multipart_data(req,params)
127
+ boundary = Time.now.to_i.to_s(16)
128
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
129
+ body = ""
130
+ params.each do |key,value|
131
+ esc_key = url_encode(key)
132
+ body << "--#{boundary}#{CRLF}"
133
+ if value.respond_to?(:read)
134
+ mime_type = MIME::Types.type_for(value.path)[0] || MIME::Types["application/octet-stream"][0]
135
+ body << "Content-Disposition: form-data; name=\"#{esc_key}\"; filename=\"#{File.basename(value.path)}\"#{CRLF}"
136
+ body << "Content-Type: #{mime_type.simplified}#{CRLF*2}"
137
+ body << value.read
138
+ else
139
+ body << "Content-Disposition: form-data; name=\"#{esc_key}\"#{CRLF*2}#{value}"
140
+ end
141
+ body << CRLF
142
+ end
143
+ body << "--#{boundary}--#{CRLF*2}"
144
+ req.body = body
145
+ req["Content-Length"] = req.body.size
146
+ end
147
+
148
+ def add_basic_auth(req,auth)
149
+ username = auth[:username]
150
+ password = auth[:password]
151
+ if username && password
152
+ req.basic_auth(username,password)
153
+ end
154
+ end
155
+
156
+ def add_oauth(conn,req,auth)
157
+ options = auth.reject do |key,value|
158
+ [:type,:consumer_key,:consumer_secret,:token,:token_secret].include?(key)
159
+ end
160
+ unless options.has_key?(:site)
161
+ options[:site] = oauth_site(conn,req)
162
+ end
163
+ consumer = OAuth::Consumer.new(auth[:consumer_key],auth[:consumer_secret],options)
164
+ access_token = OAuth::AccessToken.new(consumer,auth[:token],auth[:token_secret])
165
+ consumer.sign!(req,access_token)
166
+ end
167
+
168
+ private
169
+ def oauth_site(conn,req)
170
+ site = "#{(conn.use_ssl? ? "https" : "http")}://#{conn.address}"
171
+ if (conn.use_ssl? && conn.port != 443) || (!conn.use_ssl? && conn.port != 80)
172
+ site << ":#{conn.port}"
173
+ end
174
+ site
175
+ end
176
+
177
+ def dump_request(req)
178
+ puts "Sending Request"
179
+ puts"#{req.method} #{req.path}"
180
+ dump_headers(req)
181
+ end
182
+
183
+ def dump_response(res)
184
+ puts "Received Response"
185
+ dump_headers(res)
186
+ puts res.body
187
+ end
188
+
189
+ def dump_headers(msg)
190
+ msg.each_header do |key, value|
191
+ puts "\t#{key}=#{value}"
192
+ end
193
+ end
194
+ end
195
+ 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
@@ -0,0 +1,178 @@
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, :timeout
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_timeouts
60
+ client = new_client(200,'{"id":12345,"screen_name":"test_user"}')
61
+ assert_equal(60, client.timeout)
62
+ client.timeout = 30
63
+ assert_equal(30, client.timeout)
64
+ end
65
+
66
+ def test_simple_get_request
67
+ client = new_client(200,'{"id":12345,"screen_name":"test_user"}')
68
+ value = client.users.show.json? :screen_name=>'test_user'
69
+ assert_equal(:get,client.transport.method)
70
+ assert_equal('http',client.transport.url.scheme)
71
+ assert(!Net::HTTP.last_instance.use_ssl?,'Net::HTTP instance should not be set to use SSL')
72
+ assert_equal('twitter.com',client.transport.url.host)
73
+ assert_equal('/users/show.json',client.transport.url.path)
74
+ assert_equal('test_user',client.transport.options[:params][:screen_name])
75
+ assert_equal('screen_name=test_user',Net::HTTP.request.path.split(/\?/)[1])
76
+ assert_equal(12345,value.id)
77
+ end
78
+
79
+ def test_simple_post_request_with_basic_auth
80
+ client = Grackle::Client.new(:auth=>{:type=>:basic,:username=>'fake_user',:password=>'fake_pass'})
81
+ test_simple_post(client) do
82
+ assert_match(/Basic/i,Net::HTTP.request['Authorization'],"Request should include Authorization header for basic auth")
83
+ end
84
+ end
85
+
86
+ def test_simple_post_request_with_oauth
87
+ client = Grackle::Client.new(:auth=>{:type=>:oauth,:consumer_key=>'12345',:consumer_secret=>'abc',:token=>'wxyz',:token_secret=>'98765'})
88
+ test_simple_post(client) do
89
+ auth = Net::HTTP.request['Authorization']
90
+ assert_match(/OAuth/i,auth,"Request should include Authorization header for OAuth")
91
+ assert_match(/oauth_consumer_key="12345"/,auth,"Auth header should include consumer key")
92
+ assert_match(/oauth_token="wxyz"/,auth,"Auth header should include token")
93
+ assert_match(/oauth_signature_method="HMAC-SHA1"/,auth,"Auth header should include HMAC-SHA1 signature method as that's what Twitter supports")
94
+ end
95
+ end
96
+
97
+ def test_ssl
98
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:ssl=>true)
99
+ client.statuses.public_timeline?
100
+ assert_equal("https",client.transport.url.scheme)
101
+ assert(Net::HTTP.last_instance.use_ssl?,'Net::HTTP instance should be set to use SSL')
102
+ end
103
+
104
+ def test_default_format
105
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:default_format=>:json)
106
+ client.statuses.public_timeline?
107
+ assert_match(/\.json$/,client.transport.url.path)
108
+
109
+ client = new_client(200,'<statuses type="array"><status><id>1</id><text>test 1</text></status></statuses>',:default_format=>:xml)
110
+ client.statuses.public_timeline?
111
+ assert_match(/\.xml$/,client.transport.url.path)
112
+ end
113
+
114
+ def test_api
115
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:api=>:search)
116
+ client.search? :q=>'test'
117
+ assert_equal('search.twitter.com',client.transport.url.host)
118
+ client[:rest].users.show.some_user?
119
+ assert_equal('twitter.com',client.transport.url.host)
120
+ client.api = :search
121
+ client.trends?
122
+ assert_equal('search.twitter.com',client.transport.url.host)
123
+ end
124
+
125
+ def test_headers
126
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:headers=>{'User-Agent'=>'TestAgent/1.0','X-Test-Header'=>'Header Value'})
127
+ client.statuses.public_timeline?
128
+ assert_equal('TestAgent/1.0',Net::HTTP.request['User-Agent'],"Custom User-Agent header should have been set")
129
+ assert_equal('Header Value',Net::HTTP.request['X-Test-Header'],"Custom X-Test-Header header should have been set")
130
+ end
131
+
132
+ def test_custom_handlers
133
+ client = new_client(200,'[{"id":1,"text":"test 1"}]',:handlers=>{:json=>TestHandler.new(42)})
134
+ value = client.statuses.public_timeline.json?
135
+ assert_equal(42,value)
136
+ end
137
+
138
+ def test_clear
139
+ client = new_client(200,'[{"id":1,"text":"test 1"}]')
140
+ client.some.url.that.does.not.exist
141
+ assert_equal('/some/url/that/does/not/exist',client.send(:request).path,"An unexecuted path should be build up")
142
+ client.clear
143
+ assert_equal('',client.send(:request).path,"The path shoudl be cleared")
144
+ end
145
+
146
+ def test_file_param_triggers_multipart_encoding
147
+ client = new_client(200,'[{"id":1,"text":"test 1"}]')
148
+ client.account.update_profile_image! :image=>File.new(__FILE__)
149
+ assert_match(/multipart\/form-data/,Net::HTTP.request['Content-Type'])
150
+ end
151
+
152
+ def test_time_param_is_http_encoded_and_escaped
153
+ client = new_client(200,'[{"id":1,"text":"test 1"}]')
154
+ time = Time.now-60*60
155
+ client.statuses.public_timeline? :since=>time
156
+ assert_equal("/statuses/public_timeline.json?since=#{CGI::escape(time.httpdate)}",Net::HTTP.request.path)
157
+ end
158
+
159
+ private
160
+ def new_client(response_status, response_body, client_opts={})
161
+ client = Grackle::Client.new(client_opts)
162
+ client.transport = MockTransport.new(response_status,response_body)
163
+ client
164
+ end
165
+
166
+ def test_simple_post(client)
167
+ client.transport = MockTransport.new(200,'{"id":12345,"text":"test status"}')
168
+ value = client.statuses.update! :status=>'test status'
169
+ assert_equal(:post,client.transport.method,"Expected post request")
170
+ assert_equal('http',client.transport.url.scheme,"Expected scheme to be http")
171
+ assert_equal('twitter.com',client.transport.url.host,"Expected request to be against twitter.com")
172
+ assert_equal('/statuses/update.json',client.transport.url.path)
173
+ assert_match(/status=test%20status/,Net::HTTP.request.body,"Parameters should be form encoded")
174
+ assert_equal(12345,value.id)
175
+ yield(client) if block_given?
176
+ end
177
+
178
+ 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: r38y-grackle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ platform: ruby
6
+ authors:
7
+ - Hayes Davis
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-05-23 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
+