grackle 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/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
|
+
|