grackle 0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.rdoc +25 -0
- data/README.rdoc +171 -0
- data/grackle.gemspec +40 -0
- data/lib/grackle/client.rb +255 -0
- data/lib/grackle/handlers.rb +90 -0
- data/lib/grackle/transport.rb +201 -0
- data/lib/grackle/utils.rb +16 -0
- data/lib/grackle.rb +31 -0
- data/test/test_client.rb +225 -0
- data/test/test_grackle.rb +4 -0
- data/test/test_handlers.rb +89 -0
- data/test/test_helper.rb +3 -0
- metadata +97 -0
data/CHANGELOG.rdoc
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
== 0.1.5 (2009-10-28)
|
2
|
+
Added support for the Twitter version 1 API as a API symbol :v1
|
3
|
+
|
4
|
+
== 0.1.4 (2009-08-09)
|
5
|
+
Added additional check for 3xx responses that don't have location headers.
|
6
|
+
|
7
|
+
== 0.1.3 (2009-08-09)
|
8
|
+
Merged in changes from gotwalt for timeouts and redirects. Revised 30x
|
9
|
+
redirect handling to support a limit that prevents infinite redirects.
|
10
|
+
|
11
|
+
== 0.1.2 (2009-05-11)
|
12
|
+
Changed :site param used by OAuth to be determined dynamically unless
|
13
|
+
explicitly specified as part of the :auth param to the Client constructor.
|
14
|
+
This param needs to match the scheme and authority of the request using
|
15
|
+
OAuth or the signing will not validate.
|
16
|
+
|
17
|
+
== 0.1.1 (2009-05-10)
|
18
|
+
Fixed issue where SSL setting wasn't being applied correctly to Net:HTTP
|
19
|
+
which was preventing SSL-enabled requests from working correctly.
|
20
|
+
|
21
|
+
== 0.1.0 (2009-04-12)
|
22
|
+
* Added OAuth authentication
|
23
|
+
* Deprecated :username and :password Grackle::Client constructor params
|
24
|
+
* Changed multipart upload implementation and removed dependency on httpclient gem
|
25
|
+
* Added dependency on mime-types gem
|
data/README.rdoc
ADDED
@@ -0,0 +1,171 @@
|
|
1
|
+
=grackle
|
2
|
+
by Hayes Davis
|
3
|
+
- http://twitter.com/hayesdavis
|
4
|
+
- hayes [at] appozite.com
|
5
|
+
- http://cheaptweet.com
|
6
|
+
- http://www.appozite.com
|
7
|
+
- http://hayesdavis.net
|
8
|
+
|
9
|
+
== DESCRIPTION
|
10
|
+
Grackle is a lightweight Ruby wrapper around the Twitter REST and Search APIs. It's based on my experience using the
|
11
|
+
Twitter API to build http://cheaptweet.com. The main goal of Grackle is to never require a release when the Twitter
|
12
|
+
API changes (which it often does) or in the face of a particular Twitter API bug. As such it's somewhat different
|
13
|
+
from other Twitter API libraries. It doesn't try to hide the Twitter "methods" under an access layer nor does it
|
14
|
+
introduce concrete classes for the various objects returned by Twitter. Instead, calls to the Grackle client map
|
15
|
+
directly to Twitter API URLs. The objects returned by API calls are generated as OpenStructs on the fly and make no
|
16
|
+
assumptions about the presence or absence of any particular attributes. Taking this approach means that changes to
|
17
|
+
URLs used by Twitter, parameters required by those URLs or return values will not require a new release. It
|
18
|
+
will potentially require, however, some modifications to your code that uses Grackle.
|
19
|
+
|
20
|
+
Grackle supports both OAuth and HTTP basic authentication.
|
21
|
+
|
22
|
+
==USING GRACKLE
|
23
|
+
|
24
|
+
Before you do anything else, you'll need to
|
25
|
+
require 'grackle'
|
26
|
+
|
27
|
+
===Creating a Grackle::Client
|
28
|
+
====Using Basic Auth
|
29
|
+
client = Grackle::Client.new(:auth=>{:type=>:basic,:username=>'your_user',:password=>'yourpass'})
|
30
|
+
|
31
|
+
====Using OAuth
|
32
|
+
client = Grackle::Client.new(:auth=>{
|
33
|
+
:type=>:oauth,
|
34
|
+
:consumer_key=>'SOMECONSUMERKEYFROMTWITTER', :consumer_secret=>'SOMECONSUMERTOKENFROMTWITTER',
|
35
|
+
:token=>'ACCESSTOKENACQUIREDONUSERSBEHALF', :token_secret=>'SUPERSECRETACCESSTOKENSECRET'
|
36
|
+
})
|
37
|
+
|
38
|
+
====Using No Auth
|
39
|
+
client = Grackle::Client.new
|
40
|
+
|
41
|
+
See Grackle::Client for more information about valid arguments to the constructor. It's quite configurable. Among other things,
|
42
|
+
you can turn on ssl and specify custom headers. The calls below are pretty much as simple as it gets.
|
43
|
+
|
44
|
+
===Grackle Method Syntax
|
45
|
+
Grackle uses a method syntax that corresponds to the Twitter API URLs with a few twists. Where you would have a slash in
|
46
|
+
a Twitter URL, that becomes a "." in a chained set of Grackle method calls. Each call in the method chain is used to build
|
47
|
+
Twitter URL path until a particular call is encountered which causes the request to be sent. Methods which will cause a
|
48
|
+
request to be execute include:
|
49
|
+
- A method call ending in "?" will cause an HTTP GET to be executed
|
50
|
+
- A method call ending in "!" will cause an HTTP POST to be executed
|
51
|
+
- If a valid format such as .json, .xml, .rss or .atom is encounted, a get will be executed with that format
|
52
|
+
- A format method can also include a ? or ! to determine GET or POST in that format respectively
|
53
|
+
|
54
|
+
===GETting Data
|
55
|
+
The preferred and simplest way of executing a GET is to use the "?" method notation. This will use the default client
|
56
|
+
format (usually JSON, but see Formats section below):
|
57
|
+
client.users.show? :screen_name=>'some_user' #http://twitter.com/users/show.json?screen_name=some_user
|
58
|
+
|
59
|
+
You can force XML format by doing:
|
60
|
+
client.users.show.xml? :screen_name=>'some_user' #http://twitter.com/users/show.xml?scren_name=some_user
|
61
|
+
|
62
|
+
You can force JSON:
|
63
|
+
client.users.show.json? :screen_name=>'some_user' #http://twitter.com/users/show.json?screen_name=some_user
|
64
|
+
|
65
|
+
Or, since Twitter also allows certain ids/screen_names to be part of their URLs, this works:
|
66
|
+
client.users.show.some_user? #http://twitter.com/users/show/some_user.json
|
67
|
+
|
68
|
+
If you use an explicit format, you can leave off the "?" like so:
|
69
|
+
client.users.show.xml :screen_name=>'some_user' #http://twitter.com/users/show.xml?scren_name=some_user
|
70
|
+
|
71
|
+
===POSTing data
|
72
|
+
To use Twitter API methods that require an HTTP POST, you need to end your method chain with a bang (!)
|
73
|
+
|
74
|
+
The preferred way is to use the Client's default format (usually JSON, but see Formats section below):
|
75
|
+
client.statuses.update! :status=>'this status is from grackle' #POST to http://twitter.com/statuses/update.json
|
76
|
+
|
77
|
+
You can force a format. To update the authenticated user's status using the XML format:
|
78
|
+
client.statuses.update.xml! :status=>'this status is from grackle' #POST to http://twitter.com/statuses/update.xml
|
79
|
+
|
80
|
+
Or, with JSON
|
81
|
+
client.statuses.update.json! :status=>'this status is from grackle' #POST to http://twitter.com/statuses/update.json
|
82
|
+
|
83
|
+
===Toggling APIs
|
84
|
+
By default, the Grackle::Client sends all requests to the unversioned Twitter REST API. If you want to send requests to
|
85
|
+
the Twitter Search API, just set Grackle::Client.api to :search. To toggle back, set it to be :rest. All requests made
|
86
|
+
after setting this 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
|
+
Twitter is introducing API versioning. Grackle now supports the version 1 API using the :v1 API name. When using the :v1
|
96
|
+
API, resources that were previously separated between the search and REST APIs are available under one unified API. To
|
97
|
+
use the :v1 API, do the following:
|
98
|
+
client[:v1].search? :q=>'grackle'
|
99
|
+
client[:v1].users.show? :id=>'hayesdavis'
|
100
|
+
|
101
|
+
If you want to use the :v1 API for all requests, you may set Grackle::Client.api to :v1 or specify the :api option in the
|
102
|
+
Grackle::Client constructor like:
|
103
|
+
client = Grackle::Client.new(:api=>:v1)
|
104
|
+
|
105
|
+
===Parameter handling
|
106
|
+
- All parameters are URL encoded as necessary.
|
107
|
+
- If you use a File object as a parameter it will be POSTed to Twitter in a multipart request.
|
108
|
+
- If you use a Time object as a parameter, .httpdate will be called on it and that value will be used
|
109
|
+
|
110
|
+
===Return Values
|
111
|
+
Regardless of the format used, Grackle returns an OpenStruct (actually a Grackle::TwitterStruct) of data. The attributes
|
112
|
+
available on these structs correspond to the data returned by Twitter.
|
113
|
+
|
114
|
+
===Dealing with Errors
|
115
|
+
If the request to Twitter does not return a status code of 200, then a TwitterError is thrown. This contains the HTTP method used,
|
116
|
+
the full request URI, the response status, the response body in text and a response object build by parsing the formatted error
|
117
|
+
returned by Twitter. It's a good idea to wrap your API calls with rescue clauses for Grackle::TwitterError.
|
118
|
+
|
119
|
+
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.
|
120
|
+
|
121
|
+
===Formats
|
122
|
+
Twitter allows you to request data in particular formats. Grackle automatically parses JSON and XML formatted responses
|
123
|
+
and returns an OpenStruct. If you specify a format that Grackle doesn't parse for you, you'll receive a string containing
|
124
|
+
the raw response body. The Grackle::Client has a default_format you can specify. By default, the default_format is :json.
|
125
|
+
If you don't include a named format in your method chain as described above, but use a "?" or "!" then the
|
126
|
+
Grackle::Client.default_format is used.
|
127
|
+
|
128
|
+
== REQUIREMENTS:
|
129
|
+
|
130
|
+
You'll need the following gems to use all features of Grackle:
|
131
|
+
- json
|
132
|
+
- oauth
|
133
|
+
- mime-types
|
134
|
+
|
135
|
+
== INSTALL:
|
136
|
+
The grackle gem is now hosted at http://gemcutter.org. If you've already setup gemcutter
|
137
|
+
in your sources, you can do the following:
|
138
|
+
sudo gem install grackle
|
139
|
+
|
140
|
+
If you haven't yet setup gemcutter in your sources, go to http://gemcutter.org and follow the instructions there.
|
141
|
+
They will likely tell you to do the following:
|
142
|
+
sudo gem install gemcutter
|
143
|
+
sudo gem tumble
|
144
|
+
|
145
|
+
Once you've done that you can do:
|
146
|
+
sudo gem install grackle
|
147
|
+
|
148
|
+
== LICENSE:
|
149
|
+
|
150
|
+
(The MIT License)
|
151
|
+
|
152
|
+
Copyright (c) 2009
|
153
|
+
|
154
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
155
|
+
a copy of this software and associated documentation files (the
|
156
|
+
'Software'), to deal in the Software without restriction, including
|
157
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
158
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
159
|
+
permit persons to whom the Software is furnished to do so, subject to
|
160
|
+
the following conditions:
|
161
|
+
|
162
|
+
The above copyright notice and this permission notice shall be
|
163
|
+
included in all copies or substantial portions of the Software.
|
164
|
+
|
165
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
166
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
167
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
168
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
169
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
170
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
171
|
+
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.5"
|
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 = ["CHANGELOG.rdoc", "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,255 @@
|
|
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
|
+
# Contains the mapping of API name symbols to actual host (and path)
|
83
|
+
# prefixes to use with requests. You can add your own to this hash and
|
84
|
+
# refer to it wherever Grackle::Client uses an API symbol. You may wish
|
85
|
+
# to do this when Twitter introduces API versions greater than 1.
|
86
|
+
TWITTER_API_HOSTS = {
|
87
|
+
:rest=>'twitter.com',
|
88
|
+
:search=>'search.twitter.com',
|
89
|
+
:v1=>'api.twitter.com/1'
|
90
|
+
}
|
91
|
+
|
92
|
+
#Basic OAuth information needed to communicate with Twitter
|
93
|
+
TWITTER_OAUTH_SPEC = {
|
94
|
+
:request_token_path=>'/oauth/request_token',
|
95
|
+
:access_token_path=>'/oauth/access_token',
|
96
|
+
:authorize_path=>'/oauth/authorize'
|
97
|
+
}
|
98
|
+
|
99
|
+
attr_accessor :auth, :handlers, :default_format, :headers, :ssl, :api, :transport, :request, :api_hosts, :timeout
|
100
|
+
|
101
|
+
# Arguments (all are optional):
|
102
|
+
# - :username - twitter username to authenticate with (deprecated in favor of :auth arg)
|
103
|
+
# - :password - twitter password to authenticate with (deprecated in favor of :auth arg)
|
104
|
+
# - :handlers - Hash of formats to Handler instances (e.g. {:json=>CustomJSONHandler.new})
|
105
|
+
# - :default_format - Symbol of format to use when no format is specified in an API call (e.g. :json, :xml)
|
106
|
+
# - :headers - Hash of string keys and values for headers to pass in the HTTP request to twitter
|
107
|
+
# - :ssl - true or false to turn SSL on or off. Default is off (i.e. http://)
|
108
|
+
# - :api - one of :rest, :search or :v1. :rest is the default
|
109
|
+
# - :auth - Hash of authentication type and credentials. Must have :type key with value one of :basic or :oauth
|
110
|
+
# - :type=>:basic - Include :username and :password keys
|
111
|
+
# - :type=>:oauth - Include :consumer_key, :consumer_secret, :token and :token_secret keys
|
112
|
+
def initialize(options={})
|
113
|
+
self.transport = Transport.new
|
114
|
+
self.handlers = {:json=>Handlers::JSONHandler.new,:xml=>Handlers::XMLHandler.new,:unknown=>Handlers::StringHandler.new}
|
115
|
+
self.handlers.merge!(options[:handlers]||{})
|
116
|
+
self.default_format = options[:default_format] || :json
|
117
|
+
self.headers = {"User-Agent"=>"Grackle/#{Grackle::VERSION}"}.merge!(options[:headers]||{})
|
118
|
+
self.ssl = options[:ssl] == true
|
119
|
+
self.api = options[:api] || :rest
|
120
|
+
self.api_hosts = TWITTER_API_HOSTS.clone
|
121
|
+
self.timeout = options[:timeout] || 60
|
122
|
+
self.auth = {}
|
123
|
+
if options.has_key?(:username) || options.has_key?(:password)
|
124
|
+
#Use basic auth if :username and :password args are passed in
|
125
|
+
self.auth.merge!({:type=>:basic,:username=>options[:username],:password=>options[:password]})
|
126
|
+
end
|
127
|
+
#Handle auth mechanism that permits basic or oauth
|
128
|
+
if options.has_key?(:auth)
|
129
|
+
self.auth = options[:auth]
|
130
|
+
if auth[:type] == :oauth
|
131
|
+
self.auth = TWITTER_OAUTH_SPEC.merge(auth)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def method_missing(name,*args)
|
137
|
+
#If method is a format name, execute using that format
|
138
|
+
if format_invocation?(name)
|
139
|
+
return call_with_format(name,*args)
|
140
|
+
end
|
141
|
+
#If method ends in ! or ? use that to determine post or get
|
142
|
+
if name.to_s =~ /^(.*)(!|\?)$/
|
143
|
+
name = $1.to_sym
|
144
|
+
#! is a post, ? is a get
|
145
|
+
self.request.method = ($2 == '!' ? :post : :get)
|
146
|
+
if format_invocation?(name)
|
147
|
+
return call_with_format(name,*args)
|
148
|
+
else
|
149
|
+
self.request << "/#{$1}"
|
150
|
+
return call_with_format(self.default_format,*args)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
#Else add to the request path
|
154
|
+
self.request << "/#{name}"
|
155
|
+
self
|
156
|
+
end
|
157
|
+
|
158
|
+
# Used to toggle APIs for a particular request without setting the Client's default API
|
159
|
+
# client[:rest].users.show.hayesdavis?
|
160
|
+
def [](api_name)
|
161
|
+
request.api = api_name
|
162
|
+
self
|
163
|
+
end
|
164
|
+
|
165
|
+
#Clears any pending request built up by chained methods but not executed
|
166
|
+
def clear
|
167
|
+
self.request = nil
|
168
|
+
end
|
169
|
+
|
170
|
+
#Deprecated in favor of using the auth attribute.
|
171
|
+
def username
|
172
|
+
if auth[:type] == :basic
|
173
|
+
auth[:username]
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
#Deprecated in favor of using the auth attribute.
|
178
|
+
def username=(value)
|
179
|
+
unless auth[:type] == :basic
|
180
|
+
auth[:type] = :basic
|
181
|
+
end
|
182
|
+
auth[:username] = value
|
183
|
+
end
|
184
|
+
|
185
|
+
#Deprecated in favor of using the auth attribute.
|
186
|
+
def password
|
187
|
+
if auth[:type] == :basic
|
188
|
+
auth[:password]
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
#Deprecated in favor of using the auth attribute.
|
193
|
+
def password=(value)
|
194
|
+
unless auth[:type] == :basic
|
195
|
+
auth[:type] = :basic
|
196
|
+
end
|
197
|
+
auth[:password] = value
|
198
|
+
end
|
199
|
+
|
200
|
+
protected
|
201
|
+
def call_with_format(format,params={})
|
202
|
+
id = params.delete(:id)
|
203
|
+
request << "/#{id}" if id
|
204
|
+
request << ".#{format}"
|
205
|
+
res = send_request(params)
|
206
|
+
process_response(format,res)
|
207
|
+
ensure
|
208
|
+
clear
|
209
|
+
end
|
210
|
+
|
211
|
+
def send_request(params)
|
212
|
+
begin
|
213
|
+
transport.request(
|
214
|
+
request.method,request.url,:auth=>auth,:headers=>headers,:params=>params, :timeout => timeout
|
215
|
+
)
|
216
|
+
rescue => e
|
217
|
+
puts e
|
218
|
+
raise TwitterError.new(request.method,request.url,nil,nil,"Unexpected failure making request: #{e}")
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
def process_response(format,res)
|
223
|
+
fmt_handler = handler(format)
|
224
|
+
begin
|
225
|
+
unless res.status == 200
|
226
|
+
handle_error_response(res,fmt_handler)
|
227
|
+
else
|
228
|
+
fmt_handler.decode_response(res.body)
|
229
|
+
end
|
230
|
+
rescue TwitterError => e
|
231
|
+
raise e
|
232
|
+
rescue => e
|
233
|
+
raise TwitterError.new(res.method,res.request_uri,res.status,res.body,"Unable to decode response: #{e}")
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
def request
|
238
|
+
@request ||= Request.new(self,api,ssl)
|
239
|
+
end
|
240
|
+
|
241
|
+
def handler(format)
|
242
|
+
handlers[format] || handlers[:unknown]
|
243
|
+
end
|
244
|
+
|
245
|
+
def handle_error_response(res,handler)
|
246
|
+
err = TwitterError.new(res.method,res.request_uri,res.status,res.body)
|
247
|
+
err.response_object = handler.decode_response(err.response_body)
|
248
|
+
raise err
|
249
|
+
end
|
250
|
+
|
251
|
+
def format_invocation?(name)
|
252
|
+
self.request.path? && VALID_FORMATS.include?(name)
|
253
|
+
end
|
254
|
+
end
|
255
|
+
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,201 @@
|
|
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
|
+
DEFAULT_REDIRECT_LIMIT = 5
|
20
|
+
|
21
|
+
def req_class(method)
|
22
|
+
case method
|
23
|
+
when :get then Net::HTTP::Get
|
24
|
+
when :post then Net::HTTP::Post
|
25
|
+
when :put then Net::HTTP::Put
|
26
|
+
when :delete then Net::HTTP::Delete
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Options are one of
|
31
|
+
# - :params - a hash of parameters to be sent with the request. If a File is a parameter value, \
|
32
|
+
# a multipart request will be sent. If a Time is included, .httpdate will be called on it.
|
33
|
+
# - :headers - a hash of headers to send with the request
|
34
|
+
# - :auth - a hash of authentication parameters for either basic or oauth
|
35
|
+
# - :timeout - timeout for the http request in seconds
|
36
|
+
def request(method, string_url, options={})
|
37
|
+
params = stringify_params(options[:params])
|
38
|
+
if method == :get && params
|
39
|
+
string_url << query_string(params)
|
40
|
+
end
|
41
|
+
url = URI.parse(string_url)
|
42
|
+
begin
|
43
|
+
execute_request(method,url,options)
|
44
|
+
rescue Timeout::Error
|
45
|
+
raise "Timeout while #{method}ing #{url.to_s}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def execute_request(method,url,options={})
|
50
|
+
conn = Net::HTTP.new(url.host, url.port)
|
51
|
+
conn.use_ssl = (url.scheme == 'https')
|
52
|
+
conn.start do |http|
|
53
|
+
req = req_class(method).new(url.request_uri)
|
54
|
+
http.read_timeout = options[:timeout]
|
55
|
+
add_headers(req,options[:headers])
|
56
|
+
if file_param?(options[:params])
|
57
|
+
add_multipart_data(req,options[:params])
|
58
|
+
else
|
59
|
+
add_form_data(req,options[:params])
|
60
|
+
end
|
61
|
+
if options.has_key? :auth
|
62
|
+
if options[:auth][:type] == :basic
|
63
|
+
add_basic_auth(req,options[:auth])
|
64
|
+
elsif options[:auth][:type] == :oauth
|
65
|
+
add_oauth(http,req,options[:auth])
|
66
|
+
end
|
67
|
+
end
|
68
|
+
dump_request(req) if debug
|
69
|
+
res = http.request(req)
|
70
|
+
dump_response(res) if debug
|
71
|
+
redirect_limit = options[:redirect_limit] || DEFAULT_REDIRECT_LIMIT
|
72
|
+
if res.code.to_s =~ /^3\d\d$/ && redirect_limit > 0 && res['location']
|
73
|
+
execute_request(method,URI.parse(res['location']),options.merge(:redirect_limit=>redirect_limit-1))
|
74
|
+
else
|
75
|
+
Response.new(method,url.to_s,res.code.to_i,res.body)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def query_string(params)
|
81
|
+
query = case params
|
82
|
+
when Hash then params.map{|key,value| url_encode_param(key,value) }.join("&")
|
83
|
+
else url_encode(params.to_s)
|
84
|
+
end
|
85
|
+
if !(query == nil || query.length == 0) && query[0,1] != '?'
|
86
|
+
query = "?#{query}"
|
87
|
+
end
|
88
|
+
query
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
def stringify_params(params)
|
93
|
+
return nil unless params
|
94
|
+
params.inject({}) do |h, pair|
|
95
|
+
key, value = pair
|
96
|
+
if value.respond_to? :httpdate
|
97
|
+
value = value.httpdate
|
98
|
+
end
|
99
|
+
h[key] = value
|
100
|
+
h
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def file_param?(params)
|
105
|
+
return false unless params
|
106
|
+
params.any? {|key,value| value.respond_to? :read }
|
107
|
+
end
|
108
|
+
|
109
|
+
def url_encode(value)
|
110
|
+
require 'cgi' unless defined?(CGI) && defined?(CGI::escape)
|
111
|
+
CGI.escape(value.to_s)
|
112
|
+
end
|
113
|
+
|
114
|
+
def url_encode_param(key,value)
|
115
|
+
"#{url_encode(key)}=#{url_encode(value)}"
|
116
|
+
end
|
117
|
+
|
118
|
+
def add_headers(req,headers)
|
119
|
+
if headers
|
120
|
+
headers.each do |header, value|
|
121
|
+
req[header] = value
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def add_form_data(req,params)
|
127
|
+
if req.request_body_permitted? && params
|
128
|
+
req.set_form_data(params)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def add_multipart_data(req,params)
|
133
|
+
boundary = Time.now.to_i.to_s(16)
|
134
|
+
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
135
|
+
body = ""
|
136
|
+
params.each do |key,value|
|
137
|
+
esc_key = url_encode(key)
|
138
|
+
body << "--#{boundary}#{CRLF}"
|
139
|
+
if value.respond_to?(:read)
|
140
|
+
mime_type = MIME::Types.type_for(value.path)[0] || MIME::Types["application/octet-stream"][0]
|
141
|
+
body << "Content-Disposition: form-data; name=\"#{esc_key}\"; filename=\"#{File.basename(value.path)}\"#{CRLF}"
|
142
|
+
body << "Content-Type: #{mime_type.simplified}#{CRLF*2}"
|
143
|
+
body << value.read
|
144
|
+
else
|
145
|
+
body << "Content-Disposition: form-data; name=\"#{esc_key}\"#{CRLF*2}#{value}"
|
146
|
+
end
|
147
|
+
body << CRLF
|
148
|
+
end
|
149
|
+
body << "--#{boundary}--#{CRLF*2}"
|
150
|
+
req.body = body
|
151
|
+
req["Content-Length"] = req.body.size
|
152
|
+
end
|
153
|
+
|
154
|
+
def add_basic_auth(req,auth)
|
155
|
+
username = auth[:username]
|
156
|
+
password = auth[:password]
|
157
|
+
if username && password
|
158
|
+
req.basic_auth(username,password)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def add_oauth(conn,req,auth)
|
163
|
+
options = auth.reject do |key,value|
|
164
|
+
[:type,:consumer_key,:consumer_secret,:token,:token_secret].include?(key)
|
165
|
+
end
|
166
|
+
unless options.has_key?(:site)
|
167
|
+
options[:site] = oauth_site(conn,req)
|
168
|
+
end
|
169
|
+
consumer = OAuth::Consumer.new(auth[:consumer_key],auth[:consumer_secret],options)
|
170
|
+
access_token = OAuth::AccessToken.new(consumer,auth[:token],auth[:token_secret])
|
171
|
+
consumer.sign!(req,access_token)
|
172
|
+
end
|
173
|
+
|
174
|
+
private
|
175
|
+
def oauth_site(conn,req)
|
176
|
+
site = "#{(conn.use_ssl? ? "https" : "http")}://#{conn.address}"
|
177
|
+
if (conn.use_ssl? && conn.port != 443) || (!conn.use_ssl? && conn.port != 80)
|
178
|
+
site << ":#{conn.port}"
|
179
|
+
end
|
180
|
+
site
|
181
|
+
end
|
182
|
+
|
183
|
+
def dump_request(req)
|
184
|
+
puts "Sending Request"
|
185
|
+
puts"#{req.method} #{req.path}"
|
186
|
+
dump_headers(req)
|
187
|
+
end
|
188
|
+
|
189
|
+
def dump_response(res)
|
190
|
+
puts "Received Response"
|
191
|
+
dump_headers(res)
|
192
|
+
puts res.body
|
193
|
+
end
|
194
|
+
|
195
|
+
def dump_headers(msg)
|
196
|
+
msg.each_header do |key, value|
|
197
|
+
puts "\t#{key}=#{value}"
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Grackle
|
2
|
+
module Utils
|
3
|
+
|
4
|
+
VALID_PROFILE_IMAGE_SIZES = [:bigger,:normal,:mini]
|
5
|
+
|
6
|
+
#Easy method for getting different sized profile images using Twitter's naming scheme
|
7
|
+
def profile_image_url(url,size=:normal)
|
8
|
+
size = VALID_PROFILE_IMAGE_SIZES.find(:normal){|s| s == size.to_sym}
|
9
|
+
return url if url.nil? || size == :normal
|
10
|
+
url.sub(/_normal\./,"_#{size.to_s}.")
|
11
|
+
end
|
12
|
+
|
13
|
+
module_function :profile_image_url
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
data/lib/grackle.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
module Grackle
|
2
|
+
|
3
|
+
# :stopdoc:
|
4
|
+
VERSION = '0.1.5'
|
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'
|
data/test/test_client.rb
ADDED
@@ -0,0 +1,225 @@
|
|
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, :responder
|
9
|
+
end
|
10
|
+
|
11
|
+
def request(req)
|
12
|
+
self.class.last_instance = self
|
13
|
+
if self.class.responder
|
14
|
+
self.class.responder.call(self,req)
|
15
|
+
else
|
16
|
+
self.class.request = req
|
17
|
+
self.class.response
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
#Mock responses that conform to HTTPResponse's interface
|
23
|
+
class MockResponse < Net::HTTPResponse
|
24
|
+
#include Net::HTTPHeader
|
25
|
+
attr_accessor :code, :body
|
26
|
+
def initialize(code,body,headers={})
|
27
|
+
super
|
28
|
+
self.code = code
|
29
|
+
self.body = body
|
30
|
+
headers.each do |name, value|
|
31
|
+
self[name] = value
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
#Transport that collects info on requests and responses for testing purposes
|
37
|
+
class MockTransport < Grackle::Transport
|
38
|
+
attr_accessor :status, :body, :method, :url, :options, :timeout
|
39
|
+
|
40
|
+
def initialize(status,body,headers={})
|
41
|
+
Net::HTTP.response = MockResponse.new(status,body,headers)
|
42
|
+
end
|
43
|
+
|
44
|
+
def request(method, string_url, options)
|
45
|
+
self.method = method
|
46
|
+
self.url = URI.parse(string_url)
|
47
|
+
self.options = options
|
48
|
+
super(method,string_url,options)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class TestHandler
|
53
|
+
attr_accessor :decode_value
|
54
|
+
|
55
|
+
def initialize(value)
|
56
|
+
self.decode_value = value
|
57
|
+
end
|
58
|
+
|
59
|
+
def decode_response(body)
|
60
|
+
decode_value
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def test_redirects
|
65
|
+
redirects = 2 #Check that we can follow 2 redirects before getting to original request
|
66
|
+
req_count = 0
|
67
|
+
responder = Proc.new do |inst, req|
|
68
|
+
req_count += 1
|
69
|
+
#Store the original request
|
70
|
+
if req_count == 1
|
71
|
+
inst.class.request = req
|
72
|
+
else
|
73
|
+
assert_equal("/somewhere_else#{req_count-1}.json",req.path)
|
74
|
+
end
|
75
|
+
if req_count <= redirects
|
76
|
+
MockResponse.new(302,"You are being redirected",'location'=>"http://twitter.com/somewhere_else#{req_count}.json")
|
77
|
+
else
|
78
|
+
inst.class.response
|
79
|
+
end
|
80
|
+
end
|
81
|
+
with_http_responder(responder) do
|
82
|
+
test_simple_get_request
|
83
|
+
end
|
84
|
+
assert_equal(redirects+1,req_count)
|
85
|
+
end
|
86
|
+
|
87
|
+
def test_timeouts
|
88
|
+
client = new_client(200,'{"id":12345,"screen_name":"test_user"}')
|
89
|
+
assert_equal(60, client.timeout)
|
90
|
+
client.timeout = 30
|
91
|
+
assert_equal(30, client.timeout)
|
92
|
+
end
|
93
|
+
|
94
|
+
def test_simple_get_request
|
95
|
+
client = new_client(200,'{"id":12345,"screen_name":"test_user"}')
|
96
|
+
value = client.users.show.json? :screen_name=>'test_user'
|
97
|
+
assert_equal(:get,client.transport.method)
|
98
|
+
assert_equal('http',client.transport.url.scheme)
|
99
|
+
assert(!Net::HTTP.last_instance.use_ssl?,'Net::HTTP instance should not be set to use SSL')
|
100
|
+
assert_equal('twitter.com',client.transport.url.host)
|
101
|
+
assert_equal('/users/show.json',client.transport.url.path)
|
102
|
+
assert_equal('test_user',client.transport.options[:params][:screen_name])
|
103
|
+
assert_equal('screen_name=test_user',Net::HTTP.request.path.split(/\?/)[1])
|
104
|
+
assert_equal(12345,value.id)
|
105
|
+
end
|
106
|
+
|
107
|
+
def test_simple_post_request_with_basic_auth
|
108
|
+
client = Grackle::Client.new(:auth=>{:type=>:basic,:username=>'fake_user',:password=>'fake_pass'})
|
109
|
+
test_simple_post(client) do
|
110
|
+
assert_match(/Basic/i,Net::HTTP.request['Authorization'],"Request should include Authorization header for basic auth")
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def test_simple_post_request_with_oauth
|
115
|
+
client = Grackle::Client.new(:auth=>{:type=>:oauth,:consumer_key=>'12345',:consumer_secret=>'abc',:token=>'wxyz',:token_secret=>'98765'})
|
116
|
+
test_simple_post(client) do
|
117
|
+
auth = Net::HTTP.request['Authorization']
|
118
|
+
assert_match(/OAuth/i,auth,"Request should include Authorization header for OAuth")
|
119
|
+
assert_match(/oauth_consumer_key="12345"/,auth,"Auth header should include consumer key")
|
120
|
+
assert_match(/oauth_token="wxyz"/,auth,"Auth header should include token")
|
121
|
+
assert_match(/oauth_signature_method="HMAC-SHA1"/,auth,"Auth header should include HMAC-SHA1 signature method as that's what Twitter supports")
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def test_ssl
|
126
|
+
client = new_client(200,'[{"id":1,"text":"test 1"}]',:ssl=>true)
|
127
|
+
client.statuses.public_timeline?
|
128
|
+
assert_equal("https",client.transport.url.scheme)
|
129
|
+
assert(Net::HTTP.last_instance.use_ssl?,'Net::HTTP instance should be set to use SSL')
|
130
|
+
end
|
131
|
+
|
132
|
+
def test_default_format
|
133
|
+
client = new_client(200,'[{"id":1,"text":"test 1"}]',:default_format=>:json)
|
134
|
+
client.statuses.public_timeline?
|
135
|
+
assert_match(/\.json$/,client.transport.url.path)
|
136
|
+
|
137
|
+
client = new_client(200,'<statuses type="array"><status><id>1</id><text>test 1</text></status></statuses>',:default_format=>:xml)
|
138
|
+
client.statuses.public_timeline?
|
139
|
+
assert_match(/\.xml$/,client.transport.url.path)
|
140
|
+
end
|
141
|
+
|
142
|
+
def test_api
|
143
|
+
client = new_client(200,'[{"id":1,"text":"test 1"}]',:api=>:search)
|
144
|
+
client.search? :q=>'test'
|
145
|
+
assert_equal('search.twitter.com',client.transport.url.host)
|
146
|
+
|
147
|
+
client[:rest].users.show.some_user?
|
148
|
+
assert_equal('twitter.com',client.transport.url.host)
|
149
|
+
|
150
|
+
client.api = :search
|
151
|
+
client.trends?
|
152
|
+
assert_equal('search.twitter.com',client.transport.url.host)
|
153
|
+
|
154
|
+
client.api = :v1
|
155
|
+
client.search? :q=>'test'
|
156
|
+
assert_equal('api.twitter.com',client.transport.url.host)
|
157
|
+
assert_match(%r{^/1/search},client.transport.url.path)
|
158
|
+
|
159
|
+
client.api = :rest
|
160
|
+
client[:v1].users.show.some_user?
|
161
|
+
assert_equal('api.twitter.com',client.transport.url.host)
|
162
|
+
assert_match(%r{^/1/users/show/some_user},client.transport.url.path)
|
163
|
+
end
|
164
|
+
|
165
|
+
def test_headers
|
166
|
+
client = new_client(200,'[{"id":1,"text":"test 1"}]',:headers=>{'User-Agent'=>'TestAgent/1.0','X-Test-Header'=>'Header Value'})
|
167
|
+
client.statuses.public_timeline?
|
168
|
+
assert_equal('TestAgent/1.0',Net::HTTP.request['User-Agent'],"Custom User-Agent header should have been set")
|
169
|
+
assert_equal('Header Value',Net::HTTP.request['X-Test-Header'],"Custom X-Test-Header header should have been set")
|
170
|
+
end
|
171
|
+
|
172
|
+
def test_custom_handlers
|
173
|
+
client = new_client(200,'[{"id":1,"text":"test 1"}]',:handlers=>{:json=>TestHandler.new(42)})
|
174
|
+
value = client.statuses.public_timeline.json?
|
175
|
+
assert_equal(42,value)
|
176
|
+
end
|
177
|
+
|
178
|
+
def test_clear
|
179
|
+
client = new_client(200,'[{"id":1,"text":"test 1"}]')
|
180
|
+
client.some.url.that.does.not.exist
|
181
|
+
assert_equal('/some/url/that/does/not/exist',client.send(:request).path,"An unexecuted path should be build up")
|
182
|
+
client.clear
|
183
|
+
assert_equal('',client.send(:request).path,"The path shoudl be cleared")
|
184
|
+
end
|
185
|
+
|
186
|
+
def test_file_param_triggers_multipart_encoding
|
187
|
+
client = new_client(200,'[{"id":1,"text":"test 1"}]')
|
188
|
+
client.account.update_profile_image! :image=>File.new(__FILE__)
|
189
|
+
assert_match(/multipart\/form-data/,Net::HTTP.request['Content-Type'])
|
190
|
+
end
|
191
|
+
|
192
|
+
def test_time_param_is_http_encoded_and_escaped
|
193
|
+
client = new_client(200,'[{"id":1,"text":"test 1"}]')
|
194
|
+
time = Time.now-60*60
|
195
|
+
client.statuses.public_timeline? :since=>time
|
196
|
+
assert_equal("/statuses/public_timeline.json?since=#{CGI::escape(time.httpdate)}",Net::HTTP.request.path)
|
197
|
+
end
|
198
|
+
|
199
|
+
private
|
200
|
+
def with_http_responder(responder)
|
201
|
+
Net::HTTP.responder = responder
|
202
|
+
yield
|
203
|
+
ensure
|
204
|
+
Net::HTTP.responder = nil
|
205
|
+
end
|
206
|
+
|
207
|
+
def new_client(response_status, response_body, client_opts={})
|
208
|
+
client = Grackle::Client.new(client_opts)
|
209
|
+
client.transport = MockTransport.new(response_status,response_body)
|
210
|
+
client
|
211
|
+
end
|
212
|
+
|
213
|
+
def test_simple_post(client)
|
214
|
+
client.transport = MockTransport.new(200,'{"id":12345,"text":"test status"}')
|
215
|
+
value = client.statuses.update! :status=>'test status'
|
216
|
+
assert_equal(:post,client.transport.method,"Expected post request")
|
217
|
+
assert_equal('http',client.transport.url.scheme,"Expected scheme to be http")
|
218
|
+
assert_equal('twitter.com',client.transport.url.host,"Expected request to be against twitter.com")
|
219
|
+
assert_equal('/statuses/update.json',client.transport.url.path)
|
220
|
+
assert_match(/status=test%20status/,Net::HTTP.request.body,"Parameters should be form encoded")
|
221
|
+
assert_equal(12345,value.id)
|
222
|
+
yield(client) if block_given?
|
223
|
+
end
|
224
|
+
|
225
|
+
end
|
@@ -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
|
data/test/test_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: grackle
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.5
|
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 -05: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
|
+
- CHANGELOG.rdoc
|
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
|
+
licenses: []
|
69
|
+
|
70
|
+
post_install_message:
|
71
|
+
rdoc_options:
|
72
|
+
- --inline-source
|
73
|
+
- --charset=UTF-8
|
74
|
+
- --main=README.rdoc
|
75
|
+
require_paths:
|
76
|
+
- lib
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: "0"
|
82
|
+
version:
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
84
|
+
requirements:
|
85
|
+
- - ">="
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: "0"
|
88
|
+
version:
|
89
|
+
requirements: []
|
90
|
+
|
91
|
+
rubyforge_project: grackle
|
92
|
+
rubygems_version: 1.3.5
|
93
|
+
signing_key:
|
94
|
+
specification_version: 2
|
95
|
+
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.
|
96
|
+
test_files: []
|
97
|
+
|