Permalink
Fetching contributors…
Cannot retrieve contributors at this time
644 lines (531 sloc) 17.8 KB
# optional http client
begin; require 'restclient' ; rescue LoadError; end
begin; gem 'em-http-request'
require 'em-http-request'; rescue LoadError; end
# optional gem
begin; require 'rack' ; rescue LoadError; end
# stdlib
require 'digest/md5'
require 'openssl'
require 'cgi'
require 'timeout'
# the data structure used in RestGraph
RestGraphStruct = Struct.new(:access_token,
:auto_decode, :timeout,
:graph_server, :old_server,
:accept, :lang,
:app_id, :secret,
:data, :cache,
:expires_in,
:log_method,
:log_handler,
:error_handler) unless defined?(RestGraphStruct)
class RestGraph < RestGraphStruct
EventStruct = Struct.new(:duration, :url) unless
defined?(::RestGraph::EventStruct)
Attributes = RestGraphStruct.members.map(&:to_sym) unless
defined?(::RestGraph::Attributes)
class Event < EventStruct
# self.class.name[/(?<=::)\w+$/] if RUBY_VERSION >= '1.9.2'
def name; self.class.name[/::\w+$/].tr(':', ''); end
def to_s; "RestGraph: spent #{sprintf('%f', duration)} #{name} #{url}";end
end
class Event::MultiDone < Event; end
class Event::Requested < Event; end
class Event::CacheHit < Event; end
class Event::Failed < Event; end
class Error < RuntimeError
class AccessToken < Error; end
class InvalidAccessToken < AccessToken; end
class MissingAccessToken < AccessToken; end
attr_reader :error, :url
def initialize error, url=''
@error, @url = error, url
super("#{error.inspect} from #{url}")
end
module Util
extend self
def parse error, url=''
return Error.new(error, url) unless error.kind_of?(Hash)
if invalid_token?(error)
InvalidAccessToken.new(error, url)
elsif missing_token?(error)
MissingAccessToken.new(error, url)
else
Error.new(error, url)
end
end
def invalid_token? error
(%w[OAuthInvalidTokenException
OAuthException].include?((error['error'] || {})['type'])) ||
(error['error_code'] == 190) # Invalid OAuth 2.0 Access Token
end
def missing_token? error
(error['error'] || {})['message'] =~ /^An active access token/ ||
(error['error_code'] == 104) # Requires valid signature
end
end
extend Util
end
# honor default attributes
Attributes.each{ |name|
module_eval <<-RUBY
def #{name}
if (r = super).nil? then self.#{name} = self.class.default_#{name}
else r end
end
RUBY
}
# setup defaults
module DefaultAttributes
extend self
def default_access_token; nil ; end
def default_auto_decode ; true ; end
def default_strict ; false ; end
def default_timeout ; 10 ; end
def default_graph_server; 'https://graph.facebook.com/'; end
def default_old_server ; 'https://api.facebook.com/' ; end
def default_accept ; 'text/javascript' ; end
def default_lang ; 'en-us' ; end
def default_app_id ; nil ; end
def default_secret ; nil ; end
def default_data ; {} ; end
def default_cache ; nil ; end
def default_expires_in ; 600 ; end
def default_log_method ; nil ; end
def default_log_handler ; nil ; end
def default_error_handler
lambda{ |error, url| raise ::RestGraph::Error.parse(error, url) }
end
end
extend DefaultAttributes
# Fallback to ruby-hmac gem in case system openssl
# lib doesn't support SHA256 (OSX 10.5)
def self.hmac_sha256 key, data
OpenSSL::HMAC.digest('sha256', key, data)
rescue RuntimeError
require 'hmac-sha2'
HMAC::SHA256.digest(key, data)
end
# begin json backend adapter
module YajlRuby
def self.extended mod
mod.const_set(:ParseError, Yajl::ParseError)
end
def json_encode hash
Yajl::Encoder.encode(hash)
end
def json_decode json
Yajl::Parser.parse(json)
end
end
module Json
def self.extended mod
mod.const_set(:ParseError, JSON::ParserError)
end
def json_encode hash
JSON.dump(hash)
end
def json_decode json
JSON.parse(json)
end
end
module Gsub
class ParseError < RuntimeError; end
def self.extended mod
mod.const_set(:ParseError, Gsub::ParseError)
end
# only works for flat hash
def json_encode hash
middle = hash.inject([]){ |r, (k, v)|
r << "\"#{k}\":\"#{v.gsub('"','\\"')}\""
}.join(',')
"{#{middle}}"
end
def json_decode json
raise NotImplementedError.new(
'You need to install either yajl-ruby, json, or json_pure gem')
end
end
def self.select_json! picked=false
if defined?(::Yajl)
extend YajlRuby
elsif defined?(::JSON)
extend Json
elsif picked
extend Gsub
else
# pick a json gem if available
%w[yajl json].each{ |json|
begin
require json
break
rescue LoadError
end
}
select_json!(true)
end
end
select_json! unless respond_to?(:json_decode)
# end json backend adapter
# common methods
def initialize o={}
o.each{ |key, value| send("#{key}=", value) if respond_to?("#{key}=") }
end
def access_token
data['access_token'] || data['oauth_token']
end
def access_token= token
data['access_token'] = token
end
def authorized?
!!access_token
end
def secret_access_token
"#{app_id}|#{secret}"
end
def lighten! o={}
[:cache, :log_method, :log_handler, :error_handler].each{ |obj|
send("#{obj}=", false) }
send(:initialize, o)
self
end
def lighten o={}
dup.lighten!(o)
end
def inspect
"#<struct RestGraph #{attributes.map{ |k, v|
"#{k}=#{v.inspect}" }.join(', ')}>"
end
def attributes
Hash[each_pair.map{ |k, v| [k, send(k)] }]
end
# graph api related methods
def url path, query={}, server=graph_server, opts={}
"#{server}#{path}#{build_query_string(query, opts)}"
end
# extra options:
# auto_decode: Bool # decode with json or not in this API request
# # default: auto_decode in rest-graph instance
# timeout: Int # the timeout for this API request
# # default: timeout in rest-graph instance
# secret: Bool # use secret_acccess_token or not
# # default: false
# cache: Bool # use cache or not; if it's false, update cache, too
# # default: true
# expires_in: Int # control when would the cache be expired
# # default: nil
# async: Bool # use eventmachine for http client or not
# # default: false, but true in aget family
# headers: Hash # additional hash you want to pass
# # default: {}
def get path, query={}, opts={}, &cb
request(opts, [:get , url(path, query, graph_server, opts)], &cb)
end
def delete path, query={}, opts={}, &cb
request(opts, [:delete, url(path, query, graph_server, opts)], &cb)
end
def post path, payload={}, query={}, opts={}, &cb
request(opts, [:post , url(path, query, graph_server, opts), payload],
&cb)
end
def put path, payload={}, query={}, opts={}, &cb
request(opts, [:put , url(path, query, graph_server, opts), payload],
&cb)
end
# request by eventmachine (em-http)
def aget path, query={}, opts={}, &cb
get(path, query, {:async => true}.merge(opts), &cb)
end
def adelete path, query={}, opts={}, &cb
delete(path, query, {:async => true}.merge(opts), &cb)
end
def apost path, payload={}, query={}, opts={}, &cb
post(path, payload, query, {:async => true}.merge(opts), &cb)
end
def aput path, payload={}, query={}, opts={}, &cb
put(path, payload, query, {:async => true}.merge(opts), &cb)
end
def multi reqs, opts={}, &cb
request({:async => true}.merge(opts),
*reqs.map{ |(meth, path, query, payload)|
[meth, url(path, query || {}, graph_server, opts), payload]
}, &cb)
end
def next_page hash, opts={}, &cb
if hash['paging'].kind_of?(Hash) && hash['paging']['next']
request(opts, [:get, URI.encode(hash['paging']['next'])], &cb)
else
yield(nil) if block_given?
end
end
def prev_page hash, opts={}, &cb
if hash['paging'].kind_of?(Hash) && hash['paging']['previous']
request(opts, [:get, URI.encode(hash['paging']['previous'])], &cb)
else
yield(nil) if block_given?
end
end
alias_method :previous_page, :prev_page
def for_pages hash, pages=1, opts={}, kind=:next_page, &cb
if pages > 1
merge_data(send(kind, hash, opts){ |result|
yield(result.freeze) if block_given?
for_pages(result, pages - 1, opts, kind, &cb) if result
}, hash)
else
yield(nil) if block_given?
hash
end
end
# cookies, app_id, secrect related below
def parse_rack_env! env
env['HTTP_COOKIE'].to_s =~ /fbs_#{app_id}=([^\;]+)/
self.data = parse_fbs!($1)
end
def parse_cookies! cookies
self.data = if fbsr = cookies["fbsr_#{app_id}"]
parse_fbsr!(fbsr)
else fbs = cookies["fbs_#{app_id}"]
parse_fbs!(fbs)
end
end
def parse_fbs! fbs
self.data = check_sig_and_return_data(
# take out facebook sometimes there but sometimes not quotes in cookies
Rack::Utils.parse_query(fbs.to_s.sub(/^"/, '').sub(/"$/, '')))
end
def parse_fbsr! fbsr
old_data = parse_signed_request!(fbsr)
# beware! maybe facebook would take out the code someday
return self.data = old_data unless old_data && old_data['code']
# passing empty redirect_uri is needed!
authorize!(:code => old_data['code'], :redirect_uri => '')
self.data = old_data.merge(data)
end
def parse_json! json
self.data = json &&
check_sig_and_return_data(self.class.json_decode(json))
rescue ParseError
self.data = nil
end
def fbs
"#{fbs_without_sig(data).join('&')}&sig=#{calculate_sig(data)}"
end
# facebook's new signed_request...
def parse_signed_request! request
sig_encoded, json_encoded = request.split('.')
return self.data = nil unless sig_encoded && json_encoded
sig, json = [sig_encoded, json_encoded].map{ |str|
"#{str.tr('-_', '+/')}==".unpack('m').first
}
self.data = check_sig_and_return_data(
self.class.json_decode(json).merge('sig' => sig)){
self.class.hmac_sha256(secret, json_encoded)
}
rescue ParseError
self.data = nil
end
# oauth related
def authorize_url opts={}
query = {:client_id => app_id, :access_token => nil}.merge(opts)
"#{graph_server}oauth/authorize#{build_query_string(query)}"
end
def authorize! opts={}
payload = {:client_id => app_id, :client_secret => secret}.merge(opts)
self.data = Rack::Utils.parse_query(
request({:auto_decode => false}.merge(opts),
[:post, url('oauth/access_token'), payload]))
end
# old rest facebook api, i will definitely love to remove them someday
def old_rest path, query={}, opts={}, &cb
uri = url("method/#{path}", {:format => 'json'}.merge(query),
old_server, opts)
if opts[:post]
request(
opts.merge(:uri => uri),
[:post,
url("method/#{path}", {:format => 'json'}, old_server, opts),
query],
&cb)
else
request(opts, [:get, uri], &cb)
end
end
def secret_old_rest path, query={}, opts={}, &cb
old_rest(path, query, {:secret => true}.merge(opts), &cb)
end
def fql code, query={}, opts={}, &cb
old_rest('fql.query', {:query => code}.merge(query), opts, &cb)
end
def fql_multi codes, query={}, opts={}, &cb
old_rest('fql.multiquery',
{:queries => self.class.json_encode(codes)}.merge(query), opts, &cb)
end
def exchange_sessions query={}, opts={}, &cb
q = {:client_id => app_id, :client_secret => secret,
:type => 'client_cred'}.merge(query)
request(opts, [:post, url('oauth/exchange_sessions', q)], &cb)
end
def request opts, *reqs, &cb
Timeout.timeout(opts[:timeout] || timeout){
reqs.each{ |(meth, uri, payload)|
next if meth != :get # only get result would get cached
cache_assign(opts, uri, nil)
} if opts[:cache] == false # remove cache if we don't want it
if opts[:async]
request_em(opts, reqs, &cb)
else
request_rc(opts, *reqs.first, &cb)
end
}
end
protected
def request_em opts, reqs
start_time = Time.now
rs = reqs.map{ |(meth, uri, payload)|
r = EM::HttpRequest.new(uri).send(meth, :body => payload,
:head => build_headers(opts))
if cached = cache_get(opts, uri)
# TODO: this is hack!!
r.instance_variable_set('@response', cached)
r.instance_variable_set('@state' , :finish)
r.on_request_complete
r.succeed(r)
else
r.callback{
cache_for(opts, uri, meth, r.response)
log(Event::Requested.new(Time.now - start_time, uri))
}
r.error{
log(Event::Failed.new(Time.now - start_time, uri))
}
end
r
}
m = EM::MultiRequest.new
rs.each.with_index{ |r, i| m.add(i, r) }
m.callback{
# TODO: how to deal with the failed?
clients = m.responses[:callback].sort.map(&:last)
results = clients.map{ |client|
post_request(opts, client.last_effective_url, client.response)
}
if reqs.size == 1
yield(results.first)
else
log(Event::MultiDone.new(Time.now - start_time,
clients.map(&:last_effective_url).join(', ')))
yield(results)
end
}
end
def request_rc opts, meth, uri, payload=nil, &cb
start_time = Time.now
post_request(opts, uri,
cache_get(opts, uri) || fetch(opts, uri, meth, payload),
&cb)
rescue RestClient::Exception => e
post_request(opts, uri, e.http_body, &cb)
ensure
log(Event::Requested.new(Time.now - start_time, uri))
end
def build_query_string query={}, opts={}
token = opts[:secret] ? secret_access_token : access_token
qq = token ? {:access_token => token}.merge(query) : query
q = qq.select{ |k, v| v } # compacting the hash
return '' if q.empty?
return '?' + q.map{ |(k, v)| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
end
def build_headers opts={}
headers = {}
headers['Accept'] = accept if accept
headers['Accept-Language'] = lang if lang
headers.merge(opts[:headers] || {})
end
def post_request opts, uri, result, &cb
if decode?(opts)
# [this].first is not needed for yajl-ruby
decoded = self.class.json_decode("[#{result}]").first
check_error(opts, uri, decoded, &cb)
else
block_given? ? yield(result) : result
end
rescue ParseError => error
error_handler.call(error, uri) if error_handler
end
def decode? opts
if opts.has_key?(:auto_decode)
opts[:auto_decode]
else
auto_decode
end
end
def check_sig_and_return_data cookies
cookies if secret && if block_given?
yield
else
calculate_sig(cookies)
end == cookies['sig']
end
def check_error opts, uri, hash
if error_handler && hash.kind_of?(Hash) &&
(hash['error'] || # from graph api
hash['error_code']) # from fql
cache_assign(opts, uri, nil)
error_handler.call(hash, uri)
else
block_given? ? yield(hash) : hash
end
end
def calculate_sig cookies
Digest::MD5.hexdigest(fbs_without_sig(cookies).join + secret)
end
def fbs_without_sig cookies
cookies.reject{ |(k, v)| k == 'sig' }.sort.map{ |a| a.join('=') }
end
def cache_key opts, uri
Digest::MD5.hexdigest(opts[:uri] || uri)
end
def cache_assign opts, uri, value
return unless cache
cache[cache_key(opts, uri)] = value
end
def cache_get opts, uri
return unless cache
start_time = Time.now
cache[cache_key(opts, uri)].tap{ |result|
log(Event::CacheHit.new(Time.now - start_time, uri)) if result
}
end
def cache_for opts, uri, meth, value
return unless cache
# fake post (opts[:post] => true) is considered get and need cache
return if meth != :get unless opts[:post]
expires = opts[:expires_in] || expires_in
if expires.kind_of?(Fixnum) && cache.method(:store).arity == -3
cache.store(cache_key(opts, uri), value,
:expires_in => expires)
else
cache_assign(opts, uri, value)
end
end
def fetch opts, uri, meth, payload
RestClient::Request.execute(:method => meth, :url => uri,
:headers => build_headers(opts),
:payload => payload).body.
tap{ |result| cache_for(opts, uri, meth, result) }
end
def merge_data lhs, rhs
[lhs, rhs].each{ |hash|
return rhs.reject{ |k, v| k == 'paging' } if
!hash.kind_of?(Hash) || !hash['data'].kind_of?(Array)
}
lhs['data'].unshift(*rhs['data'])
lhs
end
def log event
log_handler.call(event) if log_handler
log_method .call("DEBUG: #{event}") if log_method
end
end