# gem require 'rest_client' # stdlib require 'digest/md5' require 'openssl' require 'cgi' # optional gem begin require 'rack' rescue LoadError; end # the data structure used in RestGraph RestGraphStruct = Struct.new(:auto_decode, :strict, :graph_server, :old_server, :accept, :lang, :app_id, :secret, :data, :cache, :error_handler, :log_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; end class Event::Requested < Event; end class Event::CacheHit < Event; end class Error < RuntimeError class AccessToken < Error; end class InvalidAccessToken < AccessToken; end class MissingAccessToken < AccessToken; end attr_reader :error def initialize error @error = error super(error.inspect) end module Util extend self def parse error return Error.new(error) unless error.kind_of?(Hash) if invalid_token?(error) InvalidAccessToken.new(error) elsif missing_token?(error) MissingAccessToken.new(error) else Error.new(error) 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} (r = super).nil? ? (self.#{name} = self.class.default_#{name}) : r end RUBY } # setup defaults module DefaultAttributes extend self def default_auto_decode ; true ; end def default_strict ; false ; 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_error_handler lambda{ |error| raise ::RestGraph::Error.parse(error) } end def default_log_handler lambda{ |event| } 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 # for ruby version >= 1.8.7, we can simply pass sha256, # instead of OpenSSL::Digest::Digest.new('sha256') # i'll go back to original implementation once all old systems died OpenSSL::HMAC.digest(OpenSSL::Digest::Digest.new('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={} (Attributes + [:access_token]).each{ |name| send("#{name}=", o[name]) if o.key?(name) } 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! [:cache, :error_handler, :log_handler].each{ |obj| send("#{obj}=", nil) } self end def lighten dup.lighten! end def inspect super.gsub(/(\w+)=([^,]+)/){ |match| value = $2 == 'nil' ? self.class.send("default_#{$1}").inspect : $2 "#{$1}=#{value}" } end # graph api related methods def url path, query={}, server=graph_server "#{server}#{path}#{build_query_string(query)}" end def get path, query={}, opts={} request(:get , url(path, query, graph_server), opts) end def delete path, query={}, opts={} request(:delete, url(path, query, graph_server), opts) end def post path, payload, query={}, opts={} request(:post , url(path, query, graph_server), opts, payload) end def put path, payload, query={}, opts={} request(:put , url(path, query, graph_server), opts, payload) end def next_page hash, opts={} return unless hash['paging'].kind_of?(Hash) && hash['paging']['next'] request(:get , hash['paging']['next'] , opts) end def prev_page hash, opts={} return unless hash['paging'].kind_of?(Hash) && hash['paging']['previous'] request(:get , hash['paging']['previous'] , opts) end alias_method :previous_page, :prev_page def for_pages hash, pages=1, kind=:next_page, opts={} return hash if pages <= 1 if result = send(kind, hash, opts) for_pages(merge_data(result, hash), pages - 1, kind, opts) else 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 = parse_fbs!(cookies["fbs_#{app_id}"]) 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.gsub('"', ''))) end def parse_json! json self.data = json && check_sig_and_return_data(self.class.json_decode(json)) rescue ParseError 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('.') sig, json = [sig_encoded, json_encoded].map{ |str| "#{str.tr('-_', '+/')}==".unpack('m').first } self.data = self.class.json_decode(json) if secret && self.class.hmac_sha256(secret, json_encoded) == sig rescue ParseError 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={} query = {:client_id => app_id, :client_secret => secret}.merge(opts) self.data = Rack::Utils.parse_query( request(:get, url('oauth/access_token', query), :suppress_decode => true)) end # old rest facebook api, i will definitely love to remove them someday def old_rest path, query={}, opts={} request( :get, url("method/#{path}", {:format => 'json'}.merge(query), old_server), opts) end def secret_old_rest path, query={}, opts={} old_rest(path, {:access_token => secret_access_token}.merge(query), opts) end alias_method :broken_old_rest, :secret_old_rest def exchange_sessions opts={} query = {:client_id => app_id, :client_secret => secret, :type => 'client_cred'}.merge(opts) request(:post, url('oauth/exchange_sessions', query)) end def fql code, query={}, opts={} old_rest('fql.query', {:query => code}.merge(query), opts) end def fql_multi codes, query={}, opts={} old_rest('fql.multiquery', {:queries => self.class.json_encode(codes)}.merge(query), opts) end private def request meth, uri, opts={}, payload=nil start_time = Time.now post_request(cache_get(uri) || fetch(meth, uri, payload), opts) rescue RestClient::Exception => e post_request(e.http_body, opts) ensure log_handler.call(Event::Requested.new(Time.now - start_time, uri)) end def build_query_string query={} qq = access_token ? {:access_token => access_token}.merge(query) : query q = qq.select{ |k, v| v } return '' if q.empty? return '?' + q.map{ |(k, v)| "#{k}=#{CGI.escape(v.to_s)}" }.join('&') end def build_headers headers = {} headers['Accept'] = accept if accept headers['Accept-Language'] = lang if lang headers end def post_request result, opts={} if auto_decode && !opts[:suppress_decode] decoded = self.class.json_decode("[#{result}]").first check_error(if strict || !decoded.kind_of?(String) decoded else self.class.json_decode(decoded) end) else result end end def check_sig_and_return_data cookies cookies if secret && calculate_sig(cookies) == cookies['sig'] end def check_error hash if error_handler && hash.kind_of?(Hash) && (hash['error'] || # from graph api hash['error_code']) # from fql error_handler.call(hash) else 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 uri Digest::MD5.hexdigest(uri) end def cache_get uri return unless cache start_time = Time.now cache[cache_key(uri)].tap{ |result| log_handler.call(Event::CacheHit.new(Time.now - start_time, uri)) if result } end def fetch meth, uri, payload RestClient::Request.execute(:method => meth, :url => uri, :headers => build_headers, :payload => payload).body. tap{ |result| cache[cache_key(uri)] = result if cache && meth == :get } 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 end