r38y-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.
@@ -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
+