├── .rspec ├── autotest └── discover.rb ├── lib ├── koala │ ├── version.rb │ ├── http_service │ │ ├── response.rb │ │ ├── multipart_request.rb │ │ └── uploadable_io.rb │ ├── api │ │ ├── legacy.rb │ │ ├── batch_operation.rb │ │ ├── graph_batch_api.rb │ │ ├── graph_collection.rb │ │ └── rest_api.rb │ ├── utils.rb │ ├── errors.rb │ ├── api.rb │ ├── realtime_updates.rb │ ├── test_users.rb │ ├── http_service.rb │ └── oauth.rb └── koala.rb ├── spec ├── fixtures │ ├── cat.m4v │ ├── beach.jpg │ └── facebook_data.yml ├── cases │ ├── koala_test_spec.rb │ ├── utils_spec.rb │ ├── graph_api_spec.rb │ ├── koala_spec.rb │ ├── multipart_request_spec.rb │ ├── legacy_spec.rb │ ├── error_spec.rb │ ├── api_spec.rb │ ├── graph_collection_spec.rb │ ├── uploadable_io_spec.rb │ ├── realtime_updates_spec.rb │ └── test_users_spec.rb ├── support │ ├── custom_matchers.rb │ ├── uploadable_io_shared_examples.rb │ ├── rest_api_shared_examples.rb │ ├── ordered_hash.rb │ ├── mock_http_service.rb │ └── koala_test.rb └── spec_helper.rb ├── .gitignore ├── .yardopts ├── .travis.yml ├── Guardfile ├── .autotest ├── Rakefile ├── Gemfile ├── LICENSE ├── Manifest ├── koala.gemspec ├── readme.md └── changelog.md /.rspec: -------------------------------------------------------------------------------- 1 | --color --order rand -------------------------------------------------------------------------------- /autotest/discover.rb: -------------------------------------------------------------------------------- 1 | Autotest.add_discovery { "rspec2" } -------------------------------------------------------------------------------- /lib/koala/version.rb: -------------------------------------------------------------------------------- 1 | module Koala 2 | VERSION = "1.7.0rc1" 3 | end 4 | -------------------------------------------------------------------------------- /spec/fixtures/cat.m4v: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/koala/master/spec/fixtures/cat.m4v -------------------------------------------------------------------------------- /spec/fixtures/beach.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/koala/master/spec/fixtures/beach.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | .project 3 | Gemfile.lock 4 | .rvmrc 5 | *.rbc 6 | *~ 7 | .yardoc/ 8 | .DS_Store -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --readme readme.md 2 | --title "Koala Documentation" 3 | --no-private -------------------------------------------------------------------------------- /spec/cases/koala_test_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe KoalaTest do 4 | pending "should have tests, because the test suite depends on it" 5 | end -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.8.7 3 | - 1.9.2 4 | - 1.9.3 5 | - 2.0.0 6 | - jruby-18mode # JRuby in 1.8 mode 7 | - jruby-19mode # JRuby in 1.9 mode 8 | - rbx-18mode 9 | - rbx-19mode 10 | - ree -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec', :version => 2 do 2 | watch(%r{^spec/.+_spec\.rb$}) 3 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 4 | watch('spec/spec_helper.rb') { "spec" } 5 | end 6 | 7 | -------------------------------------------------------------------------------- /.autotest: -------------------------------------------------------------------------------- 1 | # Override autotest default magic to rerun all tests every time a 2 | # change is detected on the file system. 3 | class Autotest 4 | 5 | def get_to_green 6 | begin 7 | rerun_all_tests 8 | wait_for_changes unless all_good 9 | end until all_good 10 | end 11 | 12 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | 3 | begin 4 | require 'bundler/setup' 5 | Bundler::GemHelper.install_tasks 6 | rescue LoadError 7 | puts 'although not required, bundler is recommened for running the tests' 8 | end 9 | 10 | task :default => :spec 11 | 12 | require 'rspec/core/rake_task' 13 | RSpec::Core::RakeTask.new do |t| 14 | t.rspec_opts = ["--color", '--format doc'] 15 | end -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | group :development do 4 | gem "yard" 5 | end 6 | 7 | group :development, :test do 8 | gem "typhoeus" unless defined? JRUBY_VERSION 9 | 10 | # Testing infrastructure 11 | gem 'guard' 12 | gem 'guard-rspec' 13 | 14 | if RUBY_PLATFORM =~ /darwin/ 15 | # OS X integration 16 | gem "ruby_gntp" 17 | gem "rb-fsevent" 18 | end 19 | end 20 | 21 | gem "jruby-openssl" if defined? JRUBY_VERSION 22 | 23 | gemspec 24 | -------------------------------------------------------------------------------- /lib/koala/http_service/response.rb: -------------------------------------------------------------------------------- 1 | module Koala 2 | module HTTPService 3 | class Response 4 | attr_reader :status, :body, :headers 5 | 6 | # Creates a new Response object, which standardizes the response received by Facebook for use within Koala. 7 | def initialize(status, body, headers) 8 | @status = status 9 | @body = body 10 | @headers = headers 11 | end 12 | end 13 | end 14 | 15 | # @private 16 | # legacy support for when Response lived directly under Koala 17 | Response = HTTPService::Response 18 | end 19 | -------------------------------------------------------------------------------- /lib/koala/api/legacy.rb: -------------------------------------------------------------------------------- 1 | require 'koala/api' 2 | module Koala 3 | module Facebook 4 | # Legacy support for old pre-1.2 APIs 5 | 6 | # A wrapper for the old APIs deprecated in 1.2.0, which triggers a deprecation warning when used. 7 | # Otherwise, this class functions identically to API. 8 | # @see API 9 | # @private 10 | class OldAPI < API 11 | def initialize(*args) 12 | Koala::Utils.deprecate("#{self.class.name} is deprecated and will be removed in a future version; please use the API class instead.") 13 | super 14 | end 15 | end 16 | 17 | # @private 18 | class GraphAPI < OldAPI; end 19 | 20 | # @private 21 | class RestAPI < OldAPI; end 22 | 23 | # @private 24 | class GraphAndRestAPI < OldAPI; end 25 | end 26 | end -------------------------------------------------------------------------------- /spec/support/custom_matchers.rb: -------------------------------------------------------------------------------- 1 | # Verifies that two URLs are equal, ignoring the order of the query string parameters 2 | RSpec::Matchers.define :match_url do |url| 3 | match do |original_url| 4 | base, query_string = url.split("?") 5 | original_base, original_query_string = original_url.split("?") 6 | query_hash = query_to_params(query_string) 7 | original_query_hash = query_to_params(original_query_string) 8 | 9 | # the base URLs need to match 10 | base.should == original_base 11 | 12 | # the number of parameters should match (avoid one being a subset of the other) 13 | query_hash.values.length.should == original_query_hash.values.length 14 | 15 | # and ensure all the keys and values match 16 | query_hash.each_pair do |key, value| 17 | original_query_hash[key].should == value 18 | end 19 | end 20 | 21 | def query_to_params(query_string) 22 | query_string.split("&").inject({}) do |hash, segment| 23 | k, v = segment.split("=") 24 | hash[k] = v 25 | hash 26 | end 27 | end 28 | end -------------------------------------------------------------------------------- /lib/koala/utils.rb: -------------------------------------------------------------------------------- 1 | module Koala 2 | module Utils 3 | 4 | # Utility methods used by Koala. 5 | require 'logger' 6 | require 'forwardable' 7 | 8 | extend Forwardable 9 | extend self 10 | 11 | def_delegators :logger, :debug, :info, :warn, :error, :fatal, :level, :level= 12 | 13 | # The Koala logger, an instance of the standard Ruby logger, pointing to STDOUT by default. 14 | # In Rails projects, you can set this to Rails.logger. 15 | attr_accessor :logger 16 | self.logger = Logger.new(STDOUT) 17 | self.logger.level = Logger::ERROR 18 | 19 | # @private 20 | DEPRECATION_PREFIX = "KOALA: Deprecation warning: " 21 | 22 | # Prints a deprecation message. 23 | # Each individual message will only be printed once to avoid spamming. 24 | def deprecate(message) 25 | @posted_deprecations ||= [] 26 | unless @posted_deprecations.include?(message) 27 | # only include each message once 28 | Kernel.warn("#{DEPRECATION_PREFIX}#{message}") 29 | @posted_deprecations << message 30 | end 31 | end 32 | end 33 | end -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2011 Alex Koppel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | if RUBY_VERSION == '1.9.2' && RUBY_PATCHLEVEL < 290 && RUBY_ENGINE != "macruby" 2 | # In Ruby 1.9.2 versions before patchlevel 290, the default Psych 3 | # parser has an issue with YAML merge keys, which 4 | # fixtures/mock_facebook_responses.yml relies heavily on. 5 | # 6 | # Anyone using an earlier version will see missing mock response 7 | # errors when running the test suite similar to this: 8 | # 9 | # RuntimeError: 10 | # Missing a mock response for graph_api: /me/videos: source=[FILE]: post: with_token 11 | # API PATH: /me/videos?source=[FILE]&format=json&access_token=* 12 | # 13 | # For now, it seems the best fix is to just downgrade to the old syck YAML parser 14 | # for these troubled versions. 15 | # 16 | # See https://github.com/tenderlove/psych/issues/8 for more details 17 | YAML::ENGINE.yamler = 'syck' 18 | end 19 | 20 | # load the library 21 | require 'koala' 22 | 23 | # Support files 24 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 25 | 26 | # set up our testing environment 27 | # load testing data and (if needed) create test users or validate real users 28 | KoalaTest.setup_test_environment! 29 | 30 | BEACH_BALL_PATH = File.join(File.dirname(__FILE__), "fixtures", "beach.jpg") -------------------------------------------------------------------------------- /Manifest: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | LICENSE 3 | Manifest 4 | Rakefile 5 | init.rb 6 | koala.gemspec 7 | lib/koala.rb 8 | lib/koala/graph_api.rb 9 | lib/koala/http_services.rb 10 | lib/koala/realtime_updates.rb 11 | lib/koala/rest_api.rb 12 | lib/koala/test_users.rb 13 | lib/koala/uploadable_io.rb 14 | readme.md 15 | spec/facebook_data.yml 16 | spec/koala/api_base_tests.rb 17 | spec/koala/assets/beach.jpg 18 | spec/koala/graph_and_rest_api/graph_and_rest_api_no_token_tests.rb 19 | spec/koala/graph_and_rest_api/graph_and_rest_api_with_token_tests.rb 20 | spec/koala/graph_api/graph_api_no_access_token_tests.rb 21 | spec/koala/graph_api/graph_api_tests.rb 22 | spec/koala/graph_api/graph_api_with_access_token_tests.rb 23 | spec/koala/graph_api/graph_collection_tests.rb 24 | spec/koala/http_services/http_service_tests.rb 25 | spec/koala/http_services/net_http_service_tests.rb 26 | spec/koala/http_services/typhoeus_service_tests.rb 27 | spec/koala/live_testing_data_helper.rb 28 | spec/koala/oauth/oauth_tests.rb 29 | spec/koala/realtime_updates/realtime_updates_tests.rb 30 | spec/koala/rest_api/rest_api_no_access_token_tests.rb 31 | spec/koala/rest_api/rest_api_tests.rb 32 | spec/koala/rest_api/rest_api_with_access_token_tests.rb 33 | spec/koala/test_users/test_users_tests.rb 34 | spec/koala/uploadable_io/uploadable_io_tests.rb 35 | spec/koala_spec.rb 36 | spec/koala_spec_helper.rb 37 | spec/koala_spec_without_mocks.rb 38 | spec/mock_facebook_responses.yml 39 | spec/mock_http_service.rb 40 | -------------------------------------------------------------------------------- /koala.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $LOAD_PATH.unshift File.expand_path("../lib", __FILE__) 3 | require 'koala/version' 4 | 5 | Gem::Specification.new do |gem| 6 | gem.name = "koala" 7 | gem.summary = "A lightweight, flexible library for Facebook with support for the Graph API, the REST API, realtime updates, and OAuth authentication." 8 | gem.description = "Koala is a lightweight, flexible Ruby SDK for Facebook. It allows read/write access to the social graph via the Graph and REST APIs, as well as support for realtime updates and OAuth and Facebook Connect authentication. Koala is fully tested and supports Net::HTTP and Typhoeus connections out of the box and can accept custom modules for other services." 9 | gem.homepage = "http://github.com/arsduo/koala" 10 | gem.version = Koala::VERSION 11 | 12 | gem.authors = ["Alex Koppel"] 13 | gem.email = "alex@alexkoppel.com" 14 | 15 | gem.require_paths = ["lib"] 16 | gem.files = `git ls-files`.split("\n") 17 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 18 | gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 19 | 20 | gem.extra_rdoc_files = ["readme.md", "changelog.md"] 21 | gem.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Koala"] 22 | 23 | gem.add_runtime_dependency("multi_json") 24 | gem.add_runtime_dependency("faraday") 25 | gem.add_runtime_dependency("addressable") 26 | gem.add_development_dependency("rspec") 27 | gem.add_development_dependency("rake") 28 | end 29 | -------------------------------------------------------------------------------- /lib/koala/http_service/multipart_request.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | 3 | module Koala 4 | module HTTPService 5 | class MultipartRequest < Faraday::Request::Multipart 6 | # Facebook expects nested parameters to be passed in a certain way 7 | # Based on our testing (https://github.com/arsduo/koala/issues/125), 8 | # Faraday needs two changes to make that work: 9 | # 1) [] need to be escaped (e.g. params[foo]=bar ==> params%5Bfoo%5D=bar) 10 | # 2) such messages need to be multipart-encoded 11 | 12 | self.mime_type = 'multipart/form-data'.freeze 13 | 14 | def process_request?(env) 15 | # if the request values contain any hashes or arrays, multipart it 16 | super || !!(env[:body].respond_to?(:values) && env[:body].values.find {|v| v.is_a?(Hash) || v.is_a?(Array)}) 17 | end 18 | 19 | 20 | def process_params(params, prefix = nil, pieces = nil, &block) 21 | params.inject(pieces || []) do |all, (key, value)| 22 | key = "#{prefix}%5B#{key}%5D" if prefix 23 | 24 | case value 25 | when Array 26 | values = value.inject([]) { |a,v| a << [nil, v] } 27 | process_params(values, key, all, &block) 28 | when Hash 29 | process_params(value, key, all, &block) 30 | else 31 | all << block.call(key, value) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | 38 | # @private 39 | # legacy support for when MultipartRequest lived directly under Koala 40 | MultipartRequest = HTTPService::MultipartRequest 41 | end -------------------------------------------------------------------------------- /spec/cases/utils_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Koala::Utils do 4 | describe ".deprecate" do 5 | before :each do 6 | # unstub deprecate so we can test it 7 | Koala::Utils.unstub(:deprecate) 8 | end 9 | 10 | it "has a deprecation prefix that includes the words Koala and deprecation" do 11 | Koala::Utils::DEPRECATION_PREFIX.should =~ /koala/i 12 | Koala::Utils::DEPRECATION_PREFIX.should =~ /deprecation/i 13 | end 14 | 15 | it "prints a warning with Kernel.warn" do 16 | message = Time.now.to_s + rand.to_s 17 | Kernel.should_receive(:warn) 18 | Koala::Utils.deprecate(message) 19 | end 20 | 21 | it "prints the deprecation prefix and the warning" do 22 | message = Time.now.to_s + rand.to_s 23 | Kernel.should_receive(:warn).with(Koala::Utils::DEPRECATION_PREFIX + message) 24 | Koala::Utils.deprecate(message) 25 | end 26 | 27 | it "only prints each unique message once" do 28 | message = Time.now.to_s + rand.to_s 29 | Kernel.should_receive(:warn).once 30 | Koala::Utils.deprecate(message) 31 | Koala::Utils.deprecate(message) 32 | end 33 | end 34 | 35 | describe ".logger" do 36 | it "has an accessor for logger" do 37 | Koala::Utils.methods.map(&:to_sym).should include(:logger) 38 | Koala::Utils.methods.map(&:to_sym).should include(:logger=) 39 | end 40 | 41 | it "defaults to the standard ruby logger with level set to ERROR" do |variable| 42 | Koala::Utils.logger.should be_kind_of(Logger) 43 | Koala::Utils.logger.level.should == Logger::ERROR 44 | end 45 | 46 | logger_methods = [:debug, :info, :warn, :error, :fatal] 47 | 48 | logger_methods.each do |logger_method| 49 | it "should delegate #{logger_method} to the attached logger" do 50 | Koala::Utils.logger.should_receive(logger_method) 51 | Koala::Utils.send(logger_method, "Test #{logger_method} message") 52 | end 53 | end 54 | end 55 | end -------------------------------------------------------------------------------- /spec/cases/graph_api_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Koala::Facebook::GraphAPIMethods' do 4 | before do 5 | @api = Koala::Facebook::API.new(@token) 6 | # app API 7 | @app_id = KoalaTest.app_id 8 | @app_access_token = KoalaTest.app_access_token 9 | @app_api = Koala::Facebook::API.new(@app_access_token) 10 | end 11 | 12 | describe 'post-processing for' do 13 | let(:result) { stub("result") } 14 | let(:post_processing) { lambda {|arg| {"result" => result, "args" => arg} } } 15 | 16 | # Most API methods have the same signature, we test get_object representatively 17 | # and the other methods which do some post-processing locally 18 | context '#get_object' do 19 | it 'returns result of block' do 20 | @api.stub(:api).and_return(stub("other results")) 21 | @api.get_object('koppel', &post_processing)["result"].should == result 22 | end 23 | end 24 | 25 | context '#get_picture' do 26 | it 'returns result of block' do 27 | @api.stub(:api).and_return("Location" => stub("other result")) 28 | @api.get_picture('lukeshepard', &post_processing)["result"].should == result 29 | end 30 | end 31 | 32 | context '#fql_multiquery' do 33 | before do 34 | @api.should_receive(:get_object).and_return([ 35 | {"name" => "query1", "fql_result_set" => [{"id" => 123}]}, 36 | {"name" => "query2", "fql_result_set" => ["id" => 456]} 37 | ]) 38 | end 39 | 40 | it 'is called with resolved response' do 41 | resolved_result = { 42 | 'query1' => [{'id' => 123}], 43 | 'query2' => [{'id' => 456}] 44 | } 45 | response = @api.fql_multiquery({}, &post_processing) 46 | response["args"].should == resolved_result 47 | response["result"].should == result 48 | end 49 | end 50 | 51 | context '#get_page_access_token' do 52 | it 'returns result of block' do 53 | token = Koala::MockHTTPService::APP_ACCESS_TOKEN 54 | @api.stub(:api).and_return("access_token" => token) 55 | response = @api.get_page_access_token('facebook', &post_processing) 56 | response["args"].should == token 57 | response["result"].should == result 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/koala.rb: -------------------------------------------------------------------------------- 1 | # useful tools 2 | require 'digest/md5' 3 | require 'multi_json' 4 | 5 | # include koala modules 6 | require 'koala/errors' 7 | require 'koala/api' 8 | require 'koala/oauth' 9 | require 'koala/realtime_updates' 10 | require 'koala/test_users' 11 | 12 | # HTTP module so we can communicate with Facebook 13 | require 'koala/http_service' 14 | 15 | # miscellaneous 16 | require 'koala/utils' 17 | require 'koala/version' 18 | require 'ostruct' 19 | 20 | module Koala 21 | # A Ruby client library for the Facebook Platform. 22 | # See http://github.com/arsduo/koala/wiki for a general introduction to Koala 23 | # and the Graph API. 24 | 25 | # Making HTTP requests 26 | class << self 27 | # Control which HTTP service framework Koala uses. 28 | # Primarily used to switch between the mock-request framework used in testing 29 | # and the live framework used in real life (and live testing). 30 | # In theory, you could write your own HTTPService module if you need different functionality, 31 | # but since the switch to {https://github.com/arsduo/koala/wiki/HTTP-Services Faraday} almost all such goals can be accomplished with middleware. 32 | attr_accessor :http_service 33 | 34 | def configure 35 | yield config 36 | end 37 | 38 | def config 39 | @config ||= OpenStruct.new(HTTPService::DEFAULT_SERVERS) 40 | end 41 | end 42 | 43 | # @private 44 | # For current HTTPServices, switch the service as expected. 45 | # For deprecated services (Typhoeus and Net::HTTP), print a warning and set the default Faraday adapter appropriately. 46 | def self.http_service=(service) 47 | if service.respond_to?(:deprecated_interface) 48 | # if this is a deprecated module, support the old interface 49 | # by changing the default adapter so the right library is used 50 | # we continue to use the single HTTPService module for everything 51 | service.deprecated_interface 52 | else 53 | # if it's a real http_service, use it 54 | @http_service = service 55 | end 56 | end 57 | 58 | # An convenenient alias to Koala.http_service.make_request. 59 | def self.make_request(path, args, verb, options = {}) 60 | http_service.make_request(path, args, verb, options) 61 | end 62 | 63 | # we use Faraday as our main service, with mock as the other main one 64 | self.http_service = HTTPService 65 | end 66 | -------------------------------------------------------------------------------- /spec/cases/koala_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Koala do 4 | it "has an http_service accessor" do 5 | Koala.should respond_to(:http_service) 6 | Koala.should respond_to(:http_service=) 7 | end 8 | 9 | describe "constants" do 10 | it "has a version" do 11 | Koala.const_defined?("VERSION").should be_true 12 | end 13 | end 14 | 15 | context "for deprecated services" do 16 | before :each do 17 | @service = Koala.http_service 18 | end 19 | 20 | after :each do 21 | Koala.http_service = @service 22 | end 23 | 24 | it "invokes deprecated_interface if present" do 25 | mock_service = stub("http service") 26 | mock_service.should_receive(:deprecated_interface) 27 | Koala.http_service = mock_service 28 | end 29 | 30 | it "does not set the service if it's deprecated" do 31 | mock_service = stub("http service") 32 | mock_service.stub(:deprecated_interface) 33 | Koala.http_service = mock_service 34 | Koala.http_service.should == @service 35 | end 36 | 37 | it "sets the service if it's not deprecated" do 38 | mock_service = stub("http service") 39 | Koala.http_service = mock_service 40 | Koala.http_service.should == mock_service 41 | end 42 | end 43 | 44 | describe "make_request" do 45 | it "passes all its arguments to the http_service" do 46 | path = "foo" 47 | args = {:a => 2} 48 | verb = "get" 49 | options = {:c => :d} 50 | 51 | Koala.http_service.should_receive(:make_request).with(path, args, verb, options) 52 | Koala.make_request(path, args, verb, options) 53 | end 54 | end 55 | 56 | describe ".configure" do 57 | it "yields a configurable object" do 58 | expect { 59 | Koala.configure {|c| c.foo = "bar"} 60 | }.not_to raise_exception 61 | end 62 | 63 | it "caches the config (singleton)" do 64 | c = Koala.config 65 | expect(c.object_id).to eq(Koala.config.object_id) 66 | end 67 | end 68 | 69 | describe ".config" do 70 | it "exposes the basic configuration" do 71 | Koala::HTTPService::DEFAULT_SERVERS.each_pair do |k, v| 72 | expect(Koala.config.send(k)).to eq(v) 73 | end 74 | end 75 | 76 | it "exposes the values configured" do 77 | Koala.configure do |config| 78 | config.graph_server = "some-new.graph_server.com" 79 | end 80 | Koala.config.graph_server.should == "some-new.graph_server.com" 81 | end 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /spec/cases/multipart_request_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Koala::HTTPService::MultipartRequest do 4 | it "is a subclass of Faraday::Request::Multipart" do 5 | Koala::HTTPService::MultipartRequest.superclass.should == Faraday::Request::Multipart 6 | end 7 | 8 | it "defines mime_type as multipart/form-data" do 9 | Koala::HTTPService::MultipartRequest.mime_type.should == 'multipart/form-data' 10 | end 11 | 12 | describe "#process_request?" do 13 | before :each do 14 | @env = {} 15 | @multipart = Koala::HTTPService::MultipartRequest.new 16 | @multipart.stub(:request_type).and_return("") 17 | end 18 | 19 | # no way to test the call to super, unfortunately 20 | it "returns true if env[:body] is a hash with at least one hash in its values" do 21 | @env[:body] = {:a => {:c => 2}} 22 | @multipart.process_request?(@env).should be_true 23 | end 24 | 25 | it "returns true if env[:body] is a hash with at least one array in its values" do 26 | @env[:body] = {:a => [:c, 2]} 27 | @multipart.process_request?(@env).should be_true 28 | end 29 | 30 | it "returns true if env[:body] is a hash with mixed objects in its values" do 31 | @env[:body] = {:a => [:c, 2], :b => {:e => :f}} 32 | @multipart.process_request?(@env).should be_true 33 | end 34 | 35 | it "returns false if env[:body] is a string" do 36 | @env[:body] = "my body" 37 | @multipart.process_request?(@env).should be_false 38 | end 39 | 40 | it "returns false if env[:body] is a hash without an array or hash value" do 41 | @env[:body] = {:a => 3} 42 | @multipart.process_request?(@env).should be_false 43 | end 44 | end 45 | 46 | describe "#process_params" do 47 | before :each do 48 | @parent = Faraday::Request::Multipart.new 49 | @multipart = Koala::HTTPService::MultipartRequest.new 50 | @block = lambda {|k, v| "#{k}=#{v}"} 51 | end 52 | 53 | it "is identical to the parent for requests without a prefix" do 54 | hash = {:a => 2, :c => "3"} 55 | @multipart.process_params(hash, &@block).should == @parent.process_params(hash, &@block) 56 | end 57 | 58 | it "replaces encodes [ and ] if the request has a prefix" do 59 | hash = {:a => 2, :c => "3"} 60 | prefix = "foo" 61 | # process_params returns an array 62 | @multipart.process_params(hash, prefix, &@block).join("&").should == @parent.process_params(hash, prefix, &@block).join("&").gsub(/\[/, "%5B").gsub(/\]/, "%5D") 63 | end 64 | end 65 | 66 | end -------------------------------------------------------------------------------- /spec/support/uploadable_io_shared_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "MIME::Types can't return results" do 2 | { 3 | "jpg" => "image/jpeg", 4 | "jpeg" => "image/jpeg", 5 | "png" => "image/png", 6 | "gif" => "image/gif" 7 | }.each_pair do |extension, mime_type| 8 | it "should properly get content types for #{extension} using basic analysis" do 9 | path = "filename.#{extension}" 10 | if @koala_io_params[0].is_a?(File) 11 | @koala_io_params[0].stub!(:path).and_return(path) 12 | else 13 | @koala_io_params[0] = path 14 | end 15 | Koala::UploadableIO.new(*@koala_io_params).content_type.should == mime_type 16 | end 17 | 18 | it "should get content types for #{extension} using basic analysis with file names with more than one dot" do 19 | path = "file.name.#{extension}" 20 | if @koala_io_params[0].is_a?(File) 21 | @koala_io_params[0].stub!(:path).and_return(path) 22 | else 23 | @koala_io_params[0] = path 24 | end 25 | Koala::UploadableIO.new(*@koala_io_params).content_type.should == mime_type 26 | end 27 | end 28 | 29 | describe "if the MIME type can't be determined" do 30 | before :each do 31 | path = "badfile.badextension" 32 | if @koala_io_params[0].is_a?(File) 33 | @koala_io_params[0].stub!(:path).and_return(path) 34 | else 35 | @koala_io_params[0] = path 36 | end 37 | end 38 | 39 | it "should throw an exception" do 40 | lambda { Koala::UploadableIO.new(*@koala_io_params) }.should raise_exception(Koala::KoalaError) 41 | end 42 | end 43 | end 44 | 45 | shared_examples_for "determining a mime type" do 46 | describe "if MIME::Types is available" do 47 | it "should return an UploadIO with MIME::Types-determined type if the type exists" do 48 | type_result = ["type"] 49 | Koala::MIME::Types.stub(:type_for).and_return(type_result) 50 | Koala::UploadableIO.new(*@koala_io_params).content_type.should == type_result.first 51 | end 52 | end 53 | 54 | describe "if MIME::Types is unavailable" do 55 | before :each do 56 | # fake that MIME::Types doesn't exist 57 | Koala::MIME::Types.stub(:type_for).and_raise(NameError) 58 | end 59 | it_should_behave_like "MIME::Types can't return results" 60 | end 61 | 62 | describe "if MIME::Types can't find the result" do 63 | before :each do 64 | # fake that MIME::Types doesn't exist 65 | Koala::MIME::Types.stub(:type_for).and_return([]) 66 | end 67 | 68 | it_should_behave_like "MIME::Types can't return results" 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/koala/api/batch_operation.rb: -------------------------------------------------------------------------------- 1 | require 'koala/api' 2 | 3 | module Koala 4 | module Facebook 5 | class GraphBatchAPI < API 6 | # @private 7 | class BatchOperation 8 | attr_reader :access_token, :http_options, :post_processing, :files, :batch_api, :identifier 9 | 10 | @identifier = 0 11 | 12 | def self.next_identifier 13 | @identifier += 1 14 | end 15 | 16 | def initialize(options = {}) 17 | @identifier = self.class.next_identifier 18 | @args = (options[:args] || {}).dup # because we modify it below 19 | @access_token = options[:access_token] 20 | @http_options = (options[:http_options] || {}).dup # dup because we modify it below 21 | @batch_args = @http_options.delete(:batch_args) || {} 22 | @url = options[:url] 23 | @method = options[:method].to_sym 24 | @post_processing = options[:post_processing] 25 | 26 | process_binary_args 27 | 28 | raise AuthenticationError.new(nil, nil, "Batch operations require an access token, none provided.") unless @access_token 29 | end 30 | 31 | def to_batch_params(main_access_token) 32 | # set up the arguments 33 | args_string = Koala.http_service.encode_params(@access_token == main_access_token ? @args : @args.merge(:access_token => @access_token)) 34 | 35 | response = { 36 | :method => @method.to_s, 37 | :relative_url => @url, 38 | } 39 | 40 | # handle batch-level arguments, such as name, depends_on, and attached_files 41 | @batch_args[:attached_files] = @files.keys.join(",") if @files 42 | response.merge!(@batch_args) if @batch_args 43 | 44 | # for get and delete, we append args to the URL string 45 | # otherwise, they go in the body 46 | if args_string.length > 0 47 | if args_in_url? 48 | response[:relative_url] += (@url =~ /\?/ ? "&" : "?") + args_string if args_string.length > 0 49 | else 50 | response[:body] = args_string if args_string.length > 0 51 | end 52 | end 53 | 54 | response 55 | end 56 | 57 | protected 58 | 59 | def process_binary_args 60 | # collect binary files 61 | @args.each_pair do |key, value| 62 | if UploadableIO.binary_content?(value) 63 | @files ||= {} 64 | # we use a class-level counter to ensure unique file identifiers across multiple batch operations 65 | # (this is thread safe, since we just care about uniqueness) 66 | # so remove the file from the original hash and add it to the file store 67 | id = "op#{identifier}_file#{@files.keys.length}" 68 | @files[id] = @args.delete(key).is_a?(UploadableIO) ? value : UploadableIO.new(value) 69 | end 70 | end 71 | end 72 | 73 | def args_in_url? 74 | @method == :get || @method == :delete 75 | end 76 | end 77 | end 78 | 79 | # @private 80 | # legacy support for when BatchOperation lived directly under Koala::Facebook 81 | BatchOperation = GraphBatchAPI::BatchOperation 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/koala/errors.rb: -------------------------------------------------------------------------------- 1 | module Koala 2 | 3 | class KoalaError < StandardError; end 4 | 5 | module Facebook 6 | 7 | # The OAuth signature is incomplete, invalid, or using an unsupported algorithm 8 | class OAuthSignatureError < ::Koala::KoalaError; end 9 | 10 | # Facebook responded with an error to an API request. If the exception contains a nil 11 | # http_status, then the error was detected before making a call to Facebook. (e.g. missing access token) 12 | class APIError < ::Koala::KoalaError 13 | attr_accessor :fb_error_type, :fb_error_code, :fb_error_subcode, :fb_error_message, 14 | :http_status, :response_body 15 | 16 | # Create a new API Error 17 | # 18 | # @param http_status [Integer] The HTTP status code of the response 19 | # @param response_body [String] The response body 20 | # @param error_info One of the following: 21 | # [Hash] The error information extracted from the request 22 | # ("type", "code", "error_subcode", "message") 23 | # [String] The error description 24 | # If error_info is nil or not provided, the method will attempt to extract 25 | # the error info from the response_body 26 | # 27 | # @return the newly created APIError 28 | def initialize(http_status, response_body, error_info = nil) 29 | if response_body 30 | self.response_body = response_body.strip 31 | else 32 | self.response_body = '' 33 | end 34 | self.http_status = http_status 35 | 36 | if error_info && error_info.is_a?(String) 37 | message = error_info 38 | else 39 | unless error_info 40 | begin 41 | error_info = MultiJson.load(response_body)['error'] if response_body 42 | rescue 43 | end 44 | error_info ||= {} 45 | end 46 | 47 | self.fb_error_type = error_info["type"] 48 | self.fb_error_code = error_info["code"] 49 | self.fb_error_subcode = error_info["error_subcode"] 50 | self.fb_error_message = error_info["message"] 51 | 52 | error_array = [] 53 | %w(type code error_subcode message).each do |key| 54 | error_array << "#{key}: #{error_info[key]}" if error_info[key] 55 | end 56 | 57 | if error_array.empty? 58 | message = self.response_body 59 | else 60 | message = error_array.join(', ') 61 | end 62 | end 63 | message += " [HTTP #{http_status}]" if http_status 64 | 65 | super(message) 66 | end 67 | end 68 | 69 | # Facebook returned an invalid response body 70 | class BadFacebookResponse < APIError; end 71 | 72 | # Facebook responded with an error while attempting to request an access token 73 | class OAuthTokenRequestError < APIError; end 74 | 75 | # Any error with a 5xx HTTP status code 76 | class ServerError < APIError; end 77 | 78 | # Any error with a 4xx HTTP status code 79 | class ClientError < APIError; end 80 | 81 | # All graph API authentication failures. 82 | class AuthenticationError < ClientError; end 83 | 84 | end 85 | 86 | end 87 | -------------------------------------------------------------------------------- /lib/koala/api/graph_batch_api.rb: -------------------------------------------------------------------------------- 1 | require 'koala/api' 2 | require 'koala/api/batch_operation' 3 | 4 | module Koala 5 | module Facebook 6 | # @private 7 | class GraphBatchAPI < API 8 | # inside a batch call we can do anything a regular Graph API can do 9 | include GraphAPIMethods 10 | 11 | attr_reader :original_api 12 | def initialize(access_token, api) 13 | super(access_token) 14 | @original_api = api 15 | end 16 | 17 | def batch_calls 18 | @batch_calls ||= [] 19 | end 20 | 21 | def graph_call_in_batch(path, args = {}, verb = "get", options = {}, &post_processing) 22 | # for batch APIs, we queue up the call details (incl. post-processing) 23 | batch_calls << BatchOperation.new( 24 | :url => path, 25 | :args => args, 26 | :method => verb, 27 | :access_token => options['access_token'] || access_token, 28 | :http_options => options, 29 | :post_processing => post_processing 30 | ) 31 | nil # batch operations return nothing immediately 32 | end 33 | 34 | # redefine the graph_call method so we can use this API inside the batch block 35 | # just like any regular Graph API 36 | alias_method :graph_call_outside_batch, :graph_call 37 | alias_method :graph_call, :graph_call_in_batch 38 | 39 | # execute the queued batch calls 40 | def execute(http_options = {}) 41 | return [] unless batch_calls.length > 0 42 | # Turn the call args collected into what facebook expects 43 | args = {} 44 | args["batch"] = MultiJson.dump(batch_calls.map { |batch_op| 45 | args.merge!(batch_op.files) if batch_op.files 46 | batch_op.to_batch_params(access_token) 47 | }) 48 | 49 | batch_result = graph_call_outside_batch('/', args, 'post', http_options) do |response| 50 | unless response 51 | # Facebook sometimes reportedly returns an empty body at times 52 | # see https://github.com/arsduo/koala/issues/184 53 | raise BadFacebookResponse.new(200, '', "Facebook returned an empty body") 54 | end 55 | 56 | # map the results with post-processing included 57 | index = 0 # keep compat with ruby 1.8 - no with_index for map 58 | response.map do |call_result| 59 | # Get the options hash 60 | batch_op = batch_calls[index] 61 | index += 1 62 | 63 | raw_result = nil 64 | if call_result 65 | if ( error = check_response(call_result['code'], call_result['body'].to_s) ) 66 | raw_result = error 67 | else 68 | # (see note in regular api method about JSON parsing) 69 | body = MultiJson.load("[#{call_result['body'].to_s}]")[0] 70 | 71 | # Get the HTTP component they want 72 | raw_result = case batch_op.http_options[:http_component] 73 | when :status 74 | call_result["code"].to_i 75 | when :headers 76 | # facebook returns the headers as an array of k/v pairs, but we want a regular hash 77 | call_result['headers'].inject({}) { |headers, h| headers[h['name']] = h['value']; headers} 78 | else 79 | body 80 | end 81 | end 82 | end 83 | 84 | # turn any results that are pageable into GraphCollections 85 | # and pass to post-processing callback if given 86 | result = GraphCollection.evaluate(raw_result, @original_api) 87 | if batch_op.post_processing 88 | batch_op.post_processing.call(result) 89 | else 90 | result 91 | end 92 | end 93 | end 94 | end 95 | 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/fixtures/facebook_data.yml: -------------------------------------------------------------------------------- 1 | # Testing data 2 | 3 | # IMPORTANT NOTE: live tests will run against a test users automatically 4 | # If you want to run them against a real user or test users on a different account, you can 5 | # by enter an OAuth token, code, and session_key (for real users) or changing the app_id and secret (for test users) 6 | # (note for real users: this will leave some photos and videos posted to your wall, since they can't be deleted through the API) 7 | 8 | # These values are configured to work with the OAuth Playground app by default 9 | # Of course, you can change this to work with your own app. 10 | # Check out http://oauth.twoalex.com/ to easily generate tokens, cookies, etc. 11 | 12 | # Your OAuth token should have the read_stream, publish_stream, user_photos, user_videos, and read_insights permissions. 13 | oauth_token: 14 | 15 | # for testing the OAuth class 16 | # baseline app 17 | oauth_test_data: 18 | # the following two values are not needed for most of the test suite, but the relevant tests will not run if they're not present 19 | code: 20 | session_key: 21 | 22 | # These values will work out of the box 23 | app_id: 119908831367602 24 | secret: e45e55a333eec232d4206d2703de1307 25 | callback_url: http://oauth.twoalex.com/ 26 | app_access_token: 119908831367602|o3wswWQ88LYjEC9-ukR_gjRIOMw. 27 | raw_token_string: "access_token=119908831367602|2.6GneoQbnEqtSiPppZzDU4Q__.3600.1273366800-2905623|3OLa3w0x1K4C1S5cOgbs07TytAk.&expires=6621" 28 | raw_offline_access_token_string: access_token=119908831367602|2.6GneoQbnEqtSiPppZzDU4Q__.3600.1273366800-2905623|3OLa3w0x1K4C1S5cOgbs07TytAk. 29 | valid_cookies: 30 | # note: the tests stub the time class so these default cookies are always valid (if you're using the default app) 31 | # if not you may want to remove the stubbing to test expiration 32 | fbs_119908831367602: '"access_token=119908831367602|2.LKE7ksSPOx0V_8mHPr2NHQ__.3600.1273363200-2905623|CMpi0AYbn03Oukzv94AUha2qbO4.&expires=1273363200&secret=lT_9zm5r5IbJ6Aa5O54nFw__&session_key=2.LKE7ksSPOx0V_8mHPr2NHQ__.3600.1273363200-2905623&sig=9515e93113921f9476a4efbdd4a3c746&uid=2905623"' 33 | offline_access_cookies: 34 | # note: I've revoked the offline access for security reasons, so you can't make calls against this :) 35 | fbs_119908831367602: '"access_token=119908831367602|08170230801eb225068e7a70-2905623|Q3LDCYYF8CX9cstxnZLsxiR0nwg.&expires=0&secret=78abaee300b392e275072a9f2727d436&session_key=08170230801eb225068e7a70-2905623&sig=423b8aa4b6fa1f9a571955f8e929d567&uid=2905623"' 36 | valid_signed_cookies: 37 | "fbsr_119908831367602": "f1--LHwjHVCxfs97hRHL-4cF-0jNxZRc6MGzo1qHLb0.eyJhbGdvcml0aG0iOiJITUFDLVNIQTI1NiIsImNvZGUiOiIyLkFRQm90a0pBWlhVY1l3RkMuMzYwMC4xMzE0ODEzNjAwLjEtMjkwNTYyM3x4V2xya0d0UmJIZlpIclRnVWwxQmxJcVhRbjQiLCJpc3N1ZWRfYXQiOjEzMTQ4MDY2NTUsInVzZXJfaWQiOiIyOTA1NjIzIn0" 38 | 39 | 40 | # These values from the OAuth Playground (see above) will work out of the box 41 | # You can update this to live data if desired 42 | # request_secret is optional and will fall back to the secret above if absent 43 | signed_params: "zWRm0gd5oHW_jzXP_WA9CirO7c5CLHotn-SKRqH2NmU.eyJhbGdvcml0aG0iOiJITUFDLVNIQTI1NiIsImV4cGlyZXMiOjEzMDE5MjIwMDAsImlzc3VlZF9hdCI6MTMwMTkxNzI5OSwib2F1dGhfdG9rZW4iOiIxMTk5MDg4MzEzNjc2MDJ8Mi56VkZfNk5yTUVMSHVKYTRnSVU5dEt3X18uMzYwMC4xMzAxOTIyMDAwLTI5MDU2MjN8emdxUHNyMkJHOUxvT0s5a2VrR2dSVVJaeDBrIiwidXNlciI6eyJjb3VudHJ5IjoiZGUiLCJsb2NhbGUiOiJkZV9ERSIsImFnZSI6eyJtaW4iOjIxfX0sInVzZXJfaWQiOiIyOTA1NjIzIn0" 44 | signed_params_result: 45 | expires: 1301922000 46 | algorithm: HMAC-SHA256 47 | user_id: "2905623" 48 | oauth_token: 119908831367602|2.zVF_6NrMELHuJa4gIU9tKw__.3600.1301922000-2905623|zgqPsr2BG9LoOK9kekGgRURZx0k 49 | user: 50 | country: de 51 | locale: de_DE 52 | age: 53 | min: 21 54 | issued_at: 1301917299 55 | 56 | subscription_test_data: 57 | subscription_path: http://oauth.twoalex.com/subscriptions 58 | verify_token: "myverificationtoken|1f54545d5f722733e17faae15377928f" 59 | challenge_data: 60 | "hub.challenge": "1290024882" 61 | "hub.verify_token": "myverificationtoken|1f54545d5f722733e17faae15377928f" 62 | "hub.mode": "subscribe" -------------------------------------------------------------------------------- /spec/cases/legacy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # Support for legacy / deprecated interfaces 4 | describe "legacy APIs" do 5 | 6 | it "deprecates the REST API" do 7 | api = Koala::Facebook::API.new 8 | api.stub(:api) 9 | Koala::Utils.should_receive(:deprecate) 10 | api.rest_call("stuff") 11 | end 12 | 13 | describe Koala::Facebook::GraphAPI do 14 | describe "class consolidation" do 15 | before :each do 16 | Koala::Utils.stub(:deprecate) # avoid actual messages to stderr 17 | end 18 | 19 | it "still allows you to instantiate a GraphAndRestAPI object" do 20 | api = Koala::Facebook::GraphAPI.new("token").should be_a(Koala::Facebook::GraphAPI) 21 | end 22 | 23 | it "ultimately creates an API object" do 24 | api = Koala::Facebook::GraphAPI.new("token").should be_a(Koala::Facebook::API) 25 | end 26 | 27 | it "fires a depreciation warning" do 28 | Koala::Utils.should_receive(:deprecate) 29 | api = Koala::Facebook::GraphAPI.new("token") 30 | end 31 | end 32 | end 33 | 34 | describe Koala::Facebook::RestAPI do 35 | describe "class consolidation" do 36 | before :each do 37 | Koala::Utils.stub(:deprecate) # avoid actual messages to stderr 38 | end 39 | 40 | it "still allows you to instantiate a GraphAndRestAPI object" do 41 | api = Koala::Facebook::RestAPI.new("token").should be_a(Koala::Facebook::RestAPI) 42 | end 43 | 44 | it "ultimately creates an API object" do 45 | api = Koala::Facebook::RestAPI.new("token").should be_a(Koala::Facebook::API) 46 | end 47 | 48 | it "fires a depreciation warning" do 49 | Koala::Utils.should_receive(:deprecate) 50 | api = Koala::Facebook::RestAPI.new("token") 51 | end 52 | end 53 | end 54 | 55 | describe Koala::Facebook::GraphAndRestAPI do 56 | describe "class consolidation" do 57 | before :each do 58 | Koala::Utils.stub(:deprecate) # avoid actual messages to stderr 59 | end 60 | 61 | it "still allows you to instantiate a GraphAndRestAPI object" do 62 | api = Koala::Facebook::GraphAndRestAPI.new("token").should be_a(Koala::Facebook::GraphAndRestAPI) 63 | end 64 | 65 | it "ultimately creates an API object" do 66 | api = Koala::Facebook::GraphAndRestAPI.new("token").should be_a(Koala::Facebook::API) 67 | end 68 | 69 | it "fires a depreciation warning" do 70 | Koala::Utils.should_receive(:deprecate) 71 | api = Koala::Facebook::GraphAndRestAPI.new("token") 72 | end 73 | end 74 | end 75 | 76 | {:typhoeus => Koala::TyphoeusService, :net_http => Koala::NetHTTPService}.each_pair do |adapter, module_class| 77 | describe module_class.to_s do 78 | it "responds to deprecated_interface" do 79 | module_class.should respond_to(:deprecated_interface) 80 | end 81 | 82 | it "issues a deprecation warning" do 83 | Koala::Utils.should_receive(:deprecate) 84 | module_class.deprecated_interface 85 | end 86 | 87 | it "sets the default adapter to #{adapter}" do 88 | module_class.deprecated_interface 89 | Faraday.default_adapter.should == adapter 90 | end 91 | end 92 | end 93 | 94 | describe "moved classes" do 95 | it "allows you to access Koala::HTTPService::MultipartRequest through the Koala module" do 96 | Koala::MultipartRequest.should == Koala::HTTPService::MultipartRequest 97 | end 98 | 99 | it "allows you to access Koala::Response through the Koala module" do 100 | Koala::Response.should == Koala::HTTPService::Response 101 | end 102 | 103 | it "allows you to access Koala::Response through the Koala module" do 104 | Koala::UploadableIO.should == Koala::HTTPService::UploadableIO 105 | end 106 | 107 | it "allows you to access Koala::Facebook::GraphBatchAPI::BatchOperation through the Koala::Facebook module" do 108 | Koala::Facebook::BatchOperation.should == Koala::Facebook::GraphBatchAPI::BatchOperation 109 | end 110 | 111 | it "allows you to access Koala::Facebook::API::GraphCollection through the Koala::Facebook module" do 112 | Koala::Facebook::GraphCollection.should == Koala::Facebook::API::GraphCollection 113 | end 114 | end 115 | end -------------------------------------------------------------------------------- /spec/cases/error_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Koala::Facebook::APIError do 4 | it "is a Koala::KoalaError" do 5 | Koala::Facebook::APIError.new(nil, nil).should be_a(Koala::KoalaError) 6 | end 7 | 8 | [:fb_error_type, :fb_error_code, :fb_error_subcode, :fb_error_message, :http_status, :response_body].each do |accessor| 9 | it "has an accessor for #{accessor}" do 10 | Koala::Facebook::APIError.instance_methods.map(&:to_sym).should include(accessor) 11 | Koala::Facebook::APIError.instance_methods.map(&:to_sym).should include(:"#{accessor}=") 12 | end 13 | end 14 | 15 | it "sets http_status to the provided status" do 16 | error_response = '{ "error": {"type": "foo", "other_details": "bar"} }' 17 | Koala::Facebook::APIError.new(400, error_response).response_body.should == error_response 18 | end 19 | 20 | it "sets response_body to the provided response body" do 21 | Koala::Facebook::APIError.new(400, '').http_status.should == 400 22 | end 23 | 24 | context "with an error_info hash" do 25 | let(:error) { 26 | error_info = { 27 | 'type' => 'type', 28 | 'message' => 'message', 29 | 'code' => 1, 30 | 'error_subcode' => 'subcode' 31 | } 32 | Koala::Facebook::APIError.new(400, '', error_info) 33 | } 34 | 35 | { 36 | :fb_error_type => 'type', 37 | :fb_error_message => 'message', 38 | :fb_error_code => 1, 39 | :fb_error_subcode => 'subcode' 40 | }.each_pair do |accessor, value| 41 | it "sets #{accessor} to #{value}" do 42 | error.send(accessor).should == value 43 | end 44 | end 45 | 46 | it "sets the error message \"type: error_info['type'], code: error_info['code'], error_subcode: error_info['error_subcode'], message: error_info['message'] [HTTP http_status]\"" do 47 | error.message.should == "type: type, code: 1, error_subcode: subcode, message: message [HTTP 400]" 48 | end 49 | end 50 | 51 | context "with an error_info string" do 52 | it "sets the error message \"error_info [HTTP http_status]\"" do 53 | error_info = "Facebook is down." 54 | error = Koala::Facebook::APIError.new(400, '', error_info) 55 | error.message.should == "Facebook is down. [HTTP 400]" 56 | end 57 | end 58 | 59 | context "with no error_info and a response_body containing error JSON" do 60 | it "should extract the error info from the response body" do 61 | response_body = '{ "error": { "type": "type", "message": "message", "code": 1, "error_subcode": "subcode" } }' 62 | error = Koala::Facebook::APIError.new(400, response_body) 63 | { 64 | :fb_error_type => 'type', 65 | :fb_error_message => 'message', 66 | :fb_error_code => 1, 67 | :fb_error_subcode => 'subcode' 68 | }.each_pair do |accessor, value| 69 | error.send(accessor).should == value 70 | end 71 | end 72 | end 73 | 74 | end 75 | 76 | describe Koala::KoalaError do 77 | it "is a StandardError" do 78 | Koala::KoalaError.new.should be_a(StandardError) 79 | end 80 | end 81 | 82 | describe Koala::Facebook::OAuthSignatureError do 83 | it "is a Koala::KoalaError" do 84 | Koala::KoalaError.new.should be_a(Koala::KoalaError) 85 | end 86 | end 87 | 88 | describe Koala::Facebook::BadFacebookResponse do 89 | it "is a Koala::Facebook::APIError" do 90 | Koala::Facebook::BadFacebookResponse.new(nil, nil).should be_a(Koala::Facebook::APIError) 91 | end 92 | end 93 | 94 | describe Koala::Facebook::OAuthTokenRequestError do 95 | it "is a Koala::Facebook::APIError" do 96 | Koala::Facebook::OAuthTokenRequestError.new(nil, nil).should be_a(Koala::Facebook::APIError) 97 | end 98 | end 99 | 100 | describe Koala::Facebook::ServerError do 101 | it "is a Koala::Facebook::APIError" do 102 | Koala::Facebook::ServerError.new(nil, nil).should be_a(Koala::Facebook::APIError) 103 | end 104 | end 105 | 106 | describe Koala::Facebook::ClientError do 107 | it "is a Koala::Facebook::APIError" do 108 | Koala::Facebook::ClientError.new(nil, nil).should be_a(Koala::Facebook::APIError) 109 | end 110 | end 111 | 112 | describe Koala::Facebook::AuthenticationError do 113 | it "is a Koala::Facebook::ClientError" do 114 | Koala::Facebook::AuthenticationError.new(nil, nil).should be_a(Koala::Facebook::ClientError) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/koala/api.rb: -------------------------------------------------------------------------------- 1 | # graph_batch_api and legacy are required at the bottom, since they depend on API being defined 2 | require 'koala/api/graph_api' 3 | require 'koala/api/rest_api' 4 | 5 | module Koala 6 | module Facebook 7 | class API 8 | # Creates a new API client. 9 | # @param [String] access_token access token 10 | # @note If no access token is provided, you can only access some public information. 11 | # @return [Koala::Facebook::API] the API client 12 | def initialize(access_token = nil) 13 | @access_token = access_token 14 | end 15 | 16 | attr_reader :access_token 17 | 18 | include GraphAPIMethods 19 | include RestAPIMethods 20 | 21 | # Makes a request to the appropriate Facebook API. 22 | # @note You'll rarely need to call this method directly. 23 | # 24 | # @see GraphAPIMethods#graph_call 25 | # @see RestAPIMethods#rest_call 26 | # 27 | # @param path the server path for this request (leading / is prepended if not present) 28 | # @param args arguments to be sent to Facebook 29 | # @param verb the HTTP method to use 30 | # @param options request-related options for Koala and Faraday. 31 | # See https://github.com/arsduo/koala/wiki/HTTP-Services for additional options. 32 | # @option options [Symbol] :http_component which part of the response (headers, body, or status) to return 33 | # @option options [Boolean] :beta use Facebook's beta tier 34 | # @option options [Boolean] :use_ssl force SSL for this request, even if it's tokenless. 35 | # (All API requests with access tokens use SSL.) 36 | # @param error_checking_block a block to evaluate the response status for additional JSON-encoded errors 37 | # 38 | # @yield The response for evaluation 39 | # 40 | # @raise [Koala::Facebook::ServerError] if Facebook returns an error (response status >= 500) 41 | # 42 | # @return the body of the response from Facebook (unless another http_component is requested) 43 | def api(path, args = {}, verb = "get", options = {}, &error_checking_block) 44 | # If a access token is explicitly provided, use that 45 | # This is explicitly needed in batch requests so GraphCollection 46 | # results preserve any specific access tokens provided 47 | args["access_token"] ||= @access_token || @app_access_token if @access_token || @app_access_token 48 | 49 | # Translate any arrays in the params into comma-separated strings 50 | args = sanitize_request_parameters(args) 51 | 52 | # add a leading / 53 | path = "/#{path}" unless path =~ /^\// 54 | 55 | # make the request via the provided service 56 | result = Koala.make_request(path, args, verb, options) 57 | 58 | if result.status.to_i >= 500 59 | raise Koala::Facebook::ServerError.new(result.status.to_i, result.body) 60 | end 61 | 62 | yield result if error_checking_block 63 | 64 | # if we want a component other than the body (e.g. redirect header for images), return that 65 | if component = options[:http_component] 66 | component == :response ? result : result.send(options[:http_component]) 67 | else 68 | # parse the body as JSON and run it through the error checker (if provided) 69 | # Note: Facebook sometimes sends results like "true" and "false", which aren't strictly objects 70 | # and cause MultiJson.load to fail -- so we account for that by wrapping the result in [] 71 | MultiJson.load("[#{result.body.to_s}]")[0] 72 | end 73 | end 74 | 75 | private 76 | 77 | # Sanitizes Ruby objects into Facebook-compatible string values. 78 | # 79 | # @param parameters a hash of parameters. 80 | # 81 | # Returns a hash in which values that are arrays of non-enumerable values 82 | # (Strings, Symbols, Numbers, etc.) are turned into comma-separated strings. 83 | def sanitize_request_parameters(parameters) 84 | parameters.reduce({}) do |result, (key, value)| 85 | # if the parameter is an array that contains non-enumerable values, 86 | # turn it into a comma-separated list 87 | # in Ruby 1.8.7, strings are enumerable, but we don't care 88 | if value.is_a?(Array) && value.none? {|entry| entry.is_a?(Enumerable) && !entry.is_a?(String)} 89 | value = value.join(",") 90 | end 91 | result.merge(key => value) 92 | end 93 | end 94 | end 95 | end 96 | end 97 | 98 | require 'koala/api/graph_batch_api' 99 | # legacy support for old pre-1.2 API interfaces 100 | require 'koala/api/legacy' -------------------------------------------------------------------------------- /lib/koala/api/graph_collection.rb: -------------------------------------------------------------------------------- 1 | require 'addressable/uri' 2 | 3 | module Koala 4 | module Facebook 5 | class API 6 | # A light wrapper for collections returned from the Graph API. 7 | # It extends Array to allow you to page backward and forward through 8 | # result sets, and providing easy access to paging information. 9 | class GraphCollection < Array 10 | 11 | # The raw paging information from Facebook (next/previous URLs). 12 | attr_reader :paging 13 | # @return [Koala::Facebook::GraphAPI] the api used to make requests. 14 | attr_reader :api 15 | # The entire raw response from Facebook. 16 | attr_reader :raw_response 17 | 18 | # Initialize the array of results and store various additional paging-related information. 19 | # 20 | # @param response the response from Facebook (a hash whose "data" key is an array) 21 | # @param api the Graph {Koala::Facebook::API API} instance to use to make calls 22 | # (usually the API that made the original call). 23 | # 24 | # @return [Koala::Facebook::GraphCollection] an initialized GraphCollection 25 | # whose paging, raw_response, and api attributes are populated. 26 | def initialize(response, api) 27 | super response["data"] 28 | @paging = response["paging"] 29 | @raw_response = response 30 | @api = api 31 | end 32 | 33 | # @private 34 | # Turn the response into a GraphCollection if they're pageable; 35 | # if not, return the original response. 36 | # The Ads API (uniquely so far) returns a hash rather than an array when queried 37 | # with get_connections. 38 | def self.evaluate(response, api) 39 | response.is_a?(Hash) && response["data"].is_a?(Array) ? self.new(response, api) : response 40 | end 41 | 42 | # Retrieve the next page of results. 43 | # 44 | # @return a GraphCollection array of additional results (an empty array if there are no more results) 45 | def next_page 46 | base, args = next_page_params 47 | base ? @api.get_page([base, args]) : nil 48 | end 49 | 50 | # Retrieve the previous page of results. 51 | # 52 | # @return a GraphCollection array of additional results (an empty array if there are no earlier results) 53 | def previous_page 54 | base, args = previous_page_params 55 | base ? @api.get_page([base, args]) : nil 56 | end 57 | 58 | # Arguments that can be sent to {Koala::Facebook::API#graph_call} to retrieve the next page of results. 59 | # 60 | # @example 61 | # @api.graph_call(*collection.next_page_params) 62 | # 63 | # @return an array of arguments, or nil if there are no more pages 64 | def next_page_params 65 | @paging && @paging["next"] ? parse_page_url(@paging["next"]) : nil 66 | end 67 | 68 | # Arguments that can be sent to {Koala::Facebook::API#graph_call} to retrieve the previous page of results. 69 | # 70 | # @example 71 | # @api.graph_call(*collection.previous_page_params) 72 | # 73 | # @return an array of arguments, or nil if there are no previous pages 74 | def previous_page_params 75 | @paging && @paging["previous"] ? parse_page_url(@paging["previous"]) : nil 76 | end 77 | 78 | # @private 79 | def parse_page_url(url) 80 | GraphCollection.parse_page_url(url) 81 | end 82 | 83 | # Parse the previous and next page URLs Facebook provides in pageable results. 84 | # You'll mainly need to use this when using a non-Rails framework (one without url_for); 85 | # to store paging information between page loads, pass the URL (from GraphCollection#paging) 86 | # and use parse_page_url to turn it into parameters useful for {Koala::Facebook::API#get_page}. 87 | # 88 | # @param url the paging URL to turn into graph_call parameters 89 | # 90 | # @return an array of parameters that can be provided via graph_call(*parsed_params) 91 | def self.parse_page_url(url) 92 | uri = Addressable::URI.parse(url) 93 | 94 | base = uri.path.sub(/^\//, '') 95 | params = CGI.parse(uri.query) 96 | 97 | new_params = {} 98 | params.each_pair do |key,value| 99 | new_params[key] = value.join "," 100 | end 101 | [base,new_params] 102 | end 103 | end 104 | end 105 | 106 | # @private 107 | # legacy support for when GraphCollection lived directly under Koala::Facebook 108 | GraphCollection = API::GraphCollection 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/support/rest_api_shared_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "Koala RestAPI" do 2 | # REST_CALL 3 | describe "when making a rest request" do 4 | it "uses the proper path" do 5 | method = stub('methodName') 6 | @api.should_receive(:api).with( 7 | "method/#{method}", 8 | anything, 9 | anything, 10 | anything 11 | ) 12 | 13 | @api.rest_call(method) 14 | end 15 | 16 | it "always uses the rest api" do 17 | @api.should_receive(:api).with( 18 | anything, 19 | anything, 20 | anything, 21 | hash_including(:rest_api => true) 22 | ) 23 | 24 | @api.rest_call('anything') 25 | end 26 | 27 | it "sets the read_only option to true if the method is listed in the read-only list" do 28 | method = Koala::Facebook::RestAPI::READ_ONLY_METHODS.first 29 | 30 | @api.should_receive(:api).with( 31 | anything, 32 | anything, 33 | anything, 34 | hash_including(:read_only => true) 35 | ) 36 | 37 | @api.rest_call(method) 38 | end 39 | 40 | it "sets the read_only option to false if the method is not inthe read-only list" do 41 | method = "I'm not a read-only method" 42 | 43 | @api.should_receive(:api).with( 44 | anything, 45 | anything, 46 | anything, 47 | hash_including(:read_only => false) 48 | ) 49 | 50 | @api.rest_call(method) 51 | end 52 | 53 | 54 | it "takes an optional hash of arguments" do 55 | args = {:arg1 => 'arg1'} 56 | 57 | @api.should_receive(:api).with( 58 | anything, 59 | hash_including(args), 60 | anything, 61 | anything 62 | ) 63 | 64 | @api.rest_call('anything', args) 65 | end 66 | 67 | it "always asks for JSON" do 68 | @api.should_receive(:api).with( 69 | anything, 70 | hash_including('format' => 'json'), 71 | anything, 72 | anything 73 | ) 74 | 75 | @api.rest_call('anything') 76 | end 77 | 78 | it "passes any options provided to the API" do 79 | options = {:a => 2} 80 | 81 | @api.should_receive(:api).with( 82 | anything, 83 | hash_including('format' => 'json'), 84 | anything, 85 | hash_including(options) 86 | ) 87 | 88 | @api.rest_call('anything', {}, options) 89 | end 90 | 91 | it "uses get by default" do 92 | @api.should_receive(:api).with( 93 | anything, 94 | anything, 95 | "get", 96 | anything 97 | ) 98 | 99 | @api.rest_call('anything') 100 | end 101 | 102 | it "allows you to specify other http methods as the last argument" do 103 | method = 'bar' 104 | @api.should_receive(:api).with( 105 | anything, 106 | anything, 107 | method, 108 | anything 109 | ) 110 | 111 | @api.rest_call('anything', {}, {}, method) 112 | end 113 | 114 | it "throws an APIError if the status code >= 400" do 115 | Koala.stub(:make_request).and_return(Koala::HTTPService::Response.new(500, '{"error_code": "An error occurred!"}', {})) 116 | lambda { @api.rest_call(KoalaTest.user1, {}) }.should raise_exception(Koala::Facebook::APIError) 117 | end 118 | end 119 | 120 | it "can use the beta tier" do 121 | @api.rest_call("fql.query", {:query => "select first_name from user where uid = #{KoalaTest.user2_id}"}, :beta => true) 122 | end 123 | end 124 | 125 | shared_examples_for "Koala RestAPI with an access token" do 126 | describe "#set_app_properties" do 127 | it "sends Facebook the properties JSON-encoded as :properties" do 128 | props = {:a => 2, :c => [1, 2, "d"]} 129 | @api.should_receive(:rest_call).with(anything, hash_including(:properties => MultiJson.dump(props)), anything, anything) 130 | @api.set_app_properties(props) 131 | end 132 | 133 | it "calls the admin.setAppProperties method" do 134 | @api.should_receive(:rest_call).with("admin.setAppProperties", anything, anything, anything) 135 | @api.set_app_properties({}) 136 | end 137 | 138 | it "includes any other provided arguments" do 139 | args = {:c => 3, :d => "a"} 140 | @api.should_receive(:rest_call).with(anything, hash_including(args), anything, anything) 141 | @api.set_app_properties({:a => 2}, args) 142 | end 143 | 144 | it "includes any http_options provided" do 145 | opts = {:c => 3, :d => "a"} 146 | @api.should_receive(:rest_call).with(anything, anything, opts, anything) 147 | @api.set_app_properties({}, {}, opts) 148 | end 149 | 150 | it "makes a POST" do 151 | @api.should_receive(:rest_call).with(anything, anything, anything, "post") 152 | @api.set_app_properties({}) 153 | end 154 | 155 | it "can set app properties using the app's access token" do 156 | oauth = Koala::Facebook::OAuth.new(KoalaTest.app_id, KoalaTest.secret) 157 | app_token = oauth.get_app_access_token 158 | @app_api = Koala::Facebook::API.new(app_token) 159 | @app_api.set_app_properties(KoalaTest.app_properties).should be_true 160 | end 161 | end 162 | end 163 | 164 | 165 | shared_examples_for "Koala RestAPI without an access token" do 166 | it "can't use set_app_properties" do 167 | lambda { @api.set_app_properties(:desktop => 0) }.should raise_error(Koala::Facebook::AuthenticationError) 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/koala/api/rest_api.rb: -------------------------------------------------------------------------------- 1 | module Koala 2 | module Facebook 3 | # Methods used to interact with Facebook's legacy REST API. 4 | # Where possible, you should use the newer, faster Graph API to interact with Facebook; 5 | # in the future, the REST API will be deprecated. 6 | # For now, though, there are a few methods that can't be done through the Graph API. 7 | # 8 | # When using the REST API, Koala will use Facebook's faster read-only servers 9 | # whenever the call allows. 10 | # 11 | # See https://github.com/arsduo/koala/wiki/REST-API for a general introduction to Koala 12 | # and the Rest API. 13 | module RestAPIMethods 14 | # Set a Facebook application's properties. 15 | # 16 | # @param properties a hash of properties you want to update with their new values. 17 | # @param (see #rest_call) 18 | # @param options (see #rest_call) 19 | # 20 | # @return true if successful, false if not. (This call currently doesn't give useful feedback on failure.) 21 | def set_app_properties(properties, args = {}, options = {}) 22 | raise AuthenticationError.new(nil, nil, "setAppProperties requires an access token") unless @access_token 23 | rest_call("admin.setAppProperties", args.merge(:properties => MultiJson.dump(properties)), options, "post") 24 | end 25 | 26 | # Make a call to the REST API. 27 | # 28 | # @note The order of the last two arguments is non-standard (for historical reasons). Sorry. 29 | # 30 | # @param fb_method the API call you want to make 31 | # @param args (see Koala::Facebook::GraphAPIMethods#graph_call) 32 | # @param options (see Koala::Facebook::GraphAPIMethods#graph_call) 33 | # @param verb (see Koala::Facebook::GraphAPIMethods#graph_call) 34 | # 35 | # @raise [Koala::Facebook::APIError] if Facebook returns an error 36 | # 37 | # @return the result from Facebook 38 | def rest_call(fb_method, args = {}, options = {}, verb = "get") 39 | Koala::Utils.deprecate("The REST API is now deprecated; please use the equivalent Graph API methods instead. See http://developers.facebook.com/blog/post/616/.") 40 | 41 | options = options.merge!(:rest_api => true, :read_only => READ_ONLY_METHODS.include?(fb_method.to_s)) 42 | 43 | api("method/#{fb_method}", args.merge('format' => 'json'), verb, options) do |response| 44 | # check for REST API-specific errors 45 | if response.status >= 400 46 | begin 47 | response_hash = MultiJson.load(response.body) 48 | rescue MultiJson::DecodeError 49 | response_hash = {} 50 | end 51 | 52 | error_info = { 53 | 'code' => response_hash['error_code'], 54 | 'error_subcode' => response_hash['error_subcode'], 55 | 'message' => response_hash['error_msg'] 56 | } 57 | 58 | if response.status >= 500 59 | raise ServerError.new(response.status, response.body, error_info) 60 | else 61 | raise ClientError.new(response.status, response.body, error_info) 62 | end 63 | end 64 | end 65 | end 66 | 67 | # @private 68 | # read-only methods for which we can use API-read 69 | # taken directly from the FB PHP library (https://github.com/facebook/php-sdk/blob/master/src/facebook.php) 70 | READ_ONLY_METHODS = [ 71 | 'admin.getallocation', 72 | 'admin.getappproperties', 73 | 'admin.getbannedusers', 74 | 'admin.getlivestreamvialink', 75 | 'admin.getmetrics', 76 | 'admin.getrestrictioninfo', 77 | 'application.getpublicinfo', 78 | 'auth.getapppublickey', 79 | 'auth.getsession', 80 | 'auth.getsignedpublicsessiondata', 81 | 'comments.get', 82 | 'connect.getunconnectedfriendscount', 83 | 'dashboard.getactivity', 84 | 'dashboard.getcount', 85 | 'dashboard.getglobalnews', 86 | 'dashboard.getnews', 87 | 'dashboard.multigetcount', 88 | 'dashboard.multigetnews', 89 | 'data.getcookies', 90 | 'events.get', 91 | 'events.getmembers', 92 | 'fbml.getcustomtags', 93 | 'feed.getappfriendstories', 94 | 'feed.getregisteredtemplatebundlebyid', 95 | 'feed.getregisteredtemplatebundles', 96 | 'fql.multiquery', 97 | 'fql.query', 98 | 'friends.arefriends', 99 | 'friends.get', 100 | 'friends.getappusers', 101 | 'friends.getlists', 102 | 'friends.getmutualfriends', 103 | 'gifts.get', 104 | 'groups.get', 105 | 'groups.getmembers', 106 | 'intl.gettranslations', 107 | 'links.get', 108 | 'notes.get', 109 | 'notifications.get', 110 | 'pages.getinfo', 111 | 'pages.isadmin', 112 | 'pages.isappadded', 113 | 'pages.isfan', 114 | 'permissions.checkavailableapiaccess', 115 | 'permissions.checkgrantedapiaccess', 116 | 'photos.get', 117 | 'photos.getalbums', 118 | 'photos.gettags', 119 | 'profile.getinfo', 120 | 'profile.getinfooptions', 121 | 'stream.get', 122 | 'stream.getcomments', 123 | 'stream.getfilters', 124 | 'users.getinfo', 125 | 'users.getloggedinuser', 126 | 'users.getstandardinfo', 127 | 'users.hasapppermission', 128 | 'users.isappuser', 129 | 'users.isverified', 130 | 'video.getuploadlimits' 131 | ] 132 | end 133 | 134 | end # module Facebook 135 | end # module Koala 136 | -------------------------------------------------------------------------------- /spec/support/ordered_hash.rb: -------------------------------------------------------------------------------- 1 | module KoalaTest 2 | # directly taken from Rails 3.1's OrderedHash 3 | # see https://github.com/rails/rails/blob/master/activesupport/lib/active_support/ordered_hash.rb 4 | 5 | # The order of iteration over hashes in Ruby 1.8 is undefined. For example, you do not know the 6 | # order in which +keys+ will return keys, or +each+ yield pairs. ActiveSupport::OrderedHash 7 | # implements a hash that preserves insertion order, as in Ruby 1.9: 8 | # 9 | # oh = ActiveSupport::OrderedHash.new 10 | # oh[:a] = 1 11 | # oh[:b] = 2 12 | # oh.keys # => [:a, :b], this order is guaranteed 13 | # 14 | # ActiveSupport::OrderedHash is namespaced to prevent conflicts with other implementations. 15 | class OrderedHash < ::Hash #:nodoc: 16 | def to_yaml_type 17 | "!tag:yaml.org,2002:omap" 18 | end 19 | 20 | def encode_with(coder) 21 | coder.represent_seq '!omap', map { |k,v| { k => v } } 22 | end 23 | 24 | def to_yaml(opts = {}) 25 | if YAML.const_defined?(:ENGINE) && !YAML::ENGINE.syck? 26 | return super 27 | end 28 | 29 | YAML.quick_emit(self, opts) do |out| 30 | out.seq(taguri) do |seq| 31 | each do |k, v| 32 | seq.add(k => v) 33 | end 34 | end 35 | end 36 | end 37 | 38 | def nested_under_indifferent_access 39 | self 40 | end 41 | 42 | # Hash is ordered in Ruby 1.9! 43 | if RUBY_VERSION < '1.9' 44 | 45 | # In MRI the Hash class is core and written in C. In particular, methods are 46 | # programmed with explicit C function calls and polymorphism is not honored. 47 | # 48 | # For example, []= is crucial in this implementation to maintain the @keys 49 | # array but hash.c invokes rb_hash_aset() originally. This prevents method 50 | # reuse through inheritance and forces us to reimplement stuff. 51 | # 52 | # For instance, we cannot use the inherited #merge! because albeit the algorithm 53 | # itself would work, our []= is not being called at all by the C code. 54 | 55 | def initialize(*args, &block) 56 | super 57 | @keys = [] 58 | end 59 | 60 | def self.[](*args) 61 | ordered_hash = new 62 | 63 | if (args.length == 1 && args.first.is_a?(Array)) 64 | args.first.each do |key_value_pair| 65 | next unless (key_value_pair.is_a?(Array)) 66 | ordered_hash[key_value_pair[0]] = key_value_pair[1] 67 | end 68 | 69 | return ordered_hash 70 | end 71 | 72 | unless (args.size % 2 == 0) 73 | raise ArgumentError.new("odd number of arguments for Hash") 74 | end 75 | 76 | args.each_with_index do |val, ind| 77 | next if (ind % 2 != 0) 78 | ordered_hash[val] = args[ind + 1] 79 | end 80 | 81 | ordered_hash 82 | end 83 | 84 | def initialize_copy(other) 85 | super 86 | # make a deep copy of keys 87 | @keys = other.keys 88 | end 89 | 90 | def []=(key, value) 91 | @keys << key unless has_key?(key) 92 | super 93 | end 94 | 95 | def delete(key) 96 | if has_key? key 97 | index = @keys.index(key) 98 | @keys.delete_at index 99 | end 100 | super 101 | end 102 | 103 | def delete_if 104 | super 105 | sync_keys! 106 | self 107 | end 108 | 109 | def reject! 110 | super 111 | sync_keys! 112 | self 113 | end 114 | 115 | def reject(&block) 116 | dup.reject!(&block) 117 | end 118 | 119 | def keys 120 | @keys.dup 121 | end 122 | 123 | def values 124 | @keys.collect { |key| self[key] } 125 | end 126 | 127 | def to_hash 128 | self 129 | end 130 | 131 | def to_a 132 | @keys.map { |key| [ key, self[key] ] } 133 | end 134 | 135 | def each_key 136 | return to_enum(:each_key) unless block_given? 137 | @keys.each { |key| yield key } 138 | self 139 | end 140 | 141 | def each_value 142 | return to_enum(:each_value) unless block_given? 143 | @keys.each { |key| yield self[key]} 144 | self 145 | end 146 | 147 | def each 148 | return to_enum(:each) unless block_given? 149 | @keys.each {|key| yield [key, self[key]]} 150 | self 151 | end 152 | 153 | alias_method :each_pair, :each 154 | 155 | alias_method :select, :find_all 156 | 157 | def clear 158 | super 159 | @keys.clear 160 | self 161 | end 162 | 163 | def shift 164 | k = @keys.first 165 | v = delete(k) 166 | [k, v] 167 | end 168 | 169 | def merge!(other_hash) 170 | if block_given? 171 | other_hash.each { |k, v| self[k] = key?(k) ? yield(k, self[k], v) : v } 172 | else 173 | other_hash.each { |k, v| self[k] = v } 174 | end 175 | self 176 | end 177 | 178 | alias_method :update, :merge! 179 | 180 | def merge(other_hash, &block) 181 | dup.merge!(other_hash, &block) 182 | end 183 | 184 | # When replacing with another hash, the initial order of our keys must come from the other hash -ordered or not. 185 | def replace(other) 186 | super 187 | @keys = other.keys 188 | self 189 | end 190 | 191 | def invert 192 | OrderedHash[self.to_a.map!{|key_value_pair| key_value_pair.reverse}] 193 | end 194 | 195 | private 196 | def sync_keys! 197 | @keys.delete_if {|k| !has_key?(k)} 198 | end 199 | end 200 | end 201 | end -------------------------------------------------------------------------------- /spec/cases/api_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Koala::Facebook::API" do 4 | before(:each) do 5 | @service = Koala::Facebook::API.new 6 | end 7 | 8 | it "doesn't include an access token if none was given" do 9 | Koala.should_receive(:make_request).with( 10 | anything, 11 | hash_not_including('access_token' => 1), 12 | anything, 13 | anything 14 | ).and_return(Koala::HTTPService::Response.new(200, "", "")) 15 | 16 | @service.api('anything') 17 | end 18 | 19 | it "includes an access token if given" do 20 | token = 'adfadf' 21 | service = Koala::Facebook::API.new token 22 | 23 | Koala.should_receive(:make_request).with( 24 | anything, 25 | hash_including('access_token' => token), 26 | anything, 27 | anything 28 | ).and_return(Koala::HTTPService::Response.new(200, "", "")) 29 | 30 | service.api('anything') 31 | end 32 | 33 | it "has an attr_reader for access token" do 34 | token = 'adfadf' 35 | service = Koala::Facebook::API.new token 36 | service.access_token.should == token 37 | end 38 | 39 | it "gets the attribute of a Koala::HTTPService::Response given by the http_component parameter" do 40 | http_component = :method_name 41 | 42 | response = mock('Mock KoalaResponse', :body => '', :status => 200) 43 | result = stub("result") 44 | response.stub(http_component).and_return(result) 45 | Koala.stub(:make_request).and_return(response) 46 | 47 | @service.api('anything', {}, 'get', :http_component => http_component).should == result 48 | end 49 | 50 | it "returns the entire response if http_component => :response" do 51 | http_component = :response 52 | response = mock('Mock KoalaResponse', :body => '', :status => 200) 53 | Koala.stub(:make_request).and_return(response) 54 | @service.api('anything', {}, 'get', :http_component => http_component).should == response 55 | end 56 | 57 | it "turns arrays of non-enumerables into comma-separated arguments" do 58 | args = [12345, {:foo => [1, 2, "3", :four]}] 59 | expected = ["/12345", {:foo => "1,2,3,four"}, "get", {}] 60 | response = mock('Mock KoalaResponse', :body => '', :status => 200) 61 | Koala.should_receive(:make_request).with(*expected).and_return(response) 62 | @service.api(*args) 63 | end 64 | 65 | it "doesn't turn arrays containing enumerables into comma-separated strings" do 66 | params = {:foo => [1, 2, ["3"], :four]} 67 | args = [12345, params] 68 | # we leave this as is -- the HTTP layer can either handle it appropriately 69 | # (if appropriate behavior is defined) 70 | # or raise an exception 71 | expected = ["/12345", params, "get", {}] 72 | response = mock('Mock KoalaResponse', :body => '', :status => 200) 73 | Koala.should_receive(:make_request).with(*expected).and_return(response) 74 | @service.api(*args) 75 | end 76 | 77 | it "returns the body of the request as JSON if no http_component is given" do 78 | response = stub('response', :body => 'body', :status => 200) 79 | Koala.stub(:make_request).and_return(response) 80 | 81 | json_body = mock('JSON body') 82 | MultiJson.stub(:load).and_return([json_body]) 83 | 84 | @service.api('anything').should == json_body 85 | end 86 | 87 | it "executes an error checking block if provided" do 88 | response = Koala::HTTPService::Response.new(200, '{}', {}) 89 | Koala.stub(:make_request).and_return(response) 90 | 91 | yield_test = mock('Yield Tester') 92 | yield_test.should_receive(:pass) 93 | 94 | @service.api('anything', {}, "get") do |arg| 95 | yield_test.pass 96 | arg.should == response 97 | end 98 | end 99 | 100 | it "raises an API error if the HTTP response code is greater than or equal to 500" do 101 | Koala.stub(:make_request).and_return(Koala::HTTPService::Response.new(500, 'response body', {})) 102 | 103 | lambda { @service.api('anything') }.should raise_exception(Koala::Facebook::APIError) 104 | end 105 | 106 | it "handles rogue true/false as responses" do 107 | Koala.should_receive(:make_request).and_return(Koala::HTTPService::Response.new(200, 'true', {})) 108 | @service.api('anything').should be_true 109 | 110 | Koala.should_receive(:make_request).and_return(Koala::HTTPService::Response.new(200, 'false', {})) 111 | @service.api('anything').should be_false 112 | end 113 | 114 | describe "with regard to leading slashes" do 115 | it "adds a leading / to the path if not present" do 116 | path = "anything" 117 | Koala.should_receive(:make_request).with("/#{path}", anything, anything, anything).and_return(Koala::HTTPService::Response.new(200, 'true', {})) 118 | @service.api(path) 119 | end 120 | 121 | it "doesn't change the path if a leading / is present" do 122 | path = "/anything" 123 | Koala.should_receive(:make_request).with(path, anything, anything, anything).and_return(Koala::HTTPService::Response.new(200, 'true', {})) 124 | @service.api(path) 125 | end 126 | end 127 | 128 | describe "with an access token" do 129 | before(:each) do 130 | @api = Koala::Facebook::API.new(@token) 131 | end 132 | 133 | it_should_behave_like "Koala RestAPI" 134 | it_should_behave_like "Koala RestAPI with an access token" 135 | 136 | it_should_behave_like "Koala GraphAPI" 137 | it_should_behave_like "Koala GraphAPI with an access token" 138 | it_should_behave_like "Koala GraphAPI with GraphCollection" 139 | end 140 | 141 | describe "without an access token" do 142 | before(:each) do 143 | @api = Koala::Facebook::API.new 144 | end 145 | 146 | it_should_behave_like "Koala RestAPI" 147 | it_should_behave_like "Koala RestAPI without an access token" 148 | 149 | it_should_behave_like "Koala GraphAPI" 150 | it_should_behave_like "Koala GraphAPI without an access token" 151 | it_should_behave_like "Koala GraphAPI with GraphCollection" 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /spec/cases/graph_collection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Koala::Facebook::GraphCollection do 4 | before(:each) do 5 | @result = { 6 | "data" => [1, 2, :three], 7 | "paging" => {:paging => true} 8 | } 9 | @api = Koala::Facebook::API.new("123") 10 | @collection = Koala::Facebook::GraphCollection.new(@result, @api) 11 | end 12 | 13 | it "subclasses Array" do 14 | Koala::Facebook::GraphCollection.ancestors.should include(Array) 15 | end 16 | 17 | it "creates an array-like object" do 18 | Koala::Facebook::GraphCollection.new(@result, @api).should be_an(Array) 19 | end 20 | 21 | it "contains the result data" do 22 | @result["data"].each_with_index {|r, i| @collection[i].should == r} 23 | end 24 | 25 | it "has a read-only paging attribute" do 26 | @collection.methods.map(&:to_sym).should include(:paging) 27 | @collection.methods.map(&:to_sym).should_not include(:paging=) 28 | end 29 | 30 | it "sets paging to results['paging']" do 31 | @collection.paging.should == @result["paging"] 32 | end 33 | 34 | it "sets raw_response to the original results" do 35 | @collection.raw_response.should == @result 36 | end 37 | 38 | it "sets the API to the provided API" do 39 | @collection.api.should == @api 40 | end 41 | 42 | describe "when getting a whole page" do 43 | before(:each) do 44 | @second_page = { 45 | "data" => [:second, :page, :data], 46 | "paging" => {} 47 | } 48 | @base = stub("base") 49 | @args = stub("args") 50 | @page_of_results = stub("page of results") 51 | end 52 | 53 | it "should return the previous page of results" do 54 | @collection.should_receive(:previous_page_params).and_return([@base, @args]) 55 | @api.should_receive(:api).with(@base, @args, anything, anything).and_return(@second_page) 56 | Koala::Facebook::GraphCollection.should_receive(:new).with(@second_page, @api).and_return(@page_of_results) 57 | @collection.previous_page.should == @page_of_results 58 | end 59 | 60 | it "should return the next page of results" do 61 | @collection.should_receive(:next_page_params).and_return([@base, @args]) 62 | @api.should_receive(:api).with(@base, @args, anything, anything).and_return(@second_page) 63 | Koala::Facebook::GraphCollection.should_receive(:new).with(@second_page, @api).and_return(@page_of_results) 64 | 65 | @collection.next_page.should == @page_of_results 66 | end 67 | 68 | it "should return nil it there are no other pages" do 69 | %w{next previous}.each do |this| 70 | @collection.should_receive("#{this}_page_params".to_sym).and_return(nil) 71 | @collection.send("#{this}_page").should == nil 72 | end 73 | end 74 | end 75 | 76 | describe "when parsing page paramters" do 77 | describe "#parse_page_url" do 78 | it "should pass the url to the class method" do 79 | url = stub("url") 80 | Koala::Facebook::GraphCollection.should_receive(:parse_page_url).with(url) 81 | @collection.parse_page_url(url) 82 | end 83 | 84 | it "should return the result of the class method" do 85 | parsed_content = stub("parsed_content") 86 | Koala::Facebook::GraphCollection.stub(:parse_page_url).and_return(parsed_content) 87 | @collection.parse_page_url(stub("url")).should == parsed_content 88 | end 89 | end 90 | 91 | describe ".parse_page_url" do 92 | it "should return the base as the first array entry" do 93 | base = "url_path" 94 | Koala::Facebook::GraphCollection.parse_page_url("http://facebook.com/#{base}?anything").first.should == base 95 | end 96 | 97 | it "should return the arguments as a hash as the last array entry" do 98 | args_hash = {"one" => "val_one", "two" => "val_two"} 99 | Koala::Facebook::GraphCollection.parse_page_url("http://facebook.com/anything?#{args_hash.map {|k,v| "#{k}=#{v}" }.join("&")}").last.should == args_hash 100 | end 101 | 102 | it "works with non-.com addresses" do 103 | base = "url_path" 104 | args_hash = {"one" => "val_one", "two" => "val_two"} 105 | Koala::Facebook::GraphCollection.parse_page_url("http://facebook.com/#{base}?#{args_hash.map {|k,v| "#{k}=#{v}" }.join("&")}").should == [base, args_hash] 106 | end 107 | 108 | it "works with addresses with irregular characters" do 109 | access_token = "appid123a|fdcba" 110 | base, args_hash = Koala::Facebook::GraphCollection.parse_page_url("http://facebook.com/foo?token=#{access_token}") 111 | args_hash["token"].should == access_token 112 | end 113 | end 114 | end 115 | 116 | describe ".evaluate" do 117 | it "returns the original result if it's provided a non-hash result" do 118 | result = [] 119 | Koala::Facebook::GraphCollection.evaluate(result, @api).should == result 120 | end 121 | 122 | it "returns the original result if it's provided a nil result" do 123 | result = nil 124 | Koala::Facebook::GraphCollection.evaluate(result, @api).should == result 125 | end 126 | 127 | it "returns the original result if the result doesn't have a data key" do 128 | result = {"paging" => {}} 129 | Koala::Facebook::GraphCollection.evaluate(result, @api).should == result 130 | end 131 | 132 | it "returns the original result if the result's data key isn't an array" do 133 | result = {"data" => {}, "paging" => {}} 134 | Koala::Facebook::GraphCollection.evaluate(result, @api).should == result 135 | end 136 | 137 | it "returns a new GraphCollection of the result if it has an array data key and a paging key" do 138 | result = {"data" => [], "paging" => {}} 139 | expected = :foo 140 | Koala::Facebook::GraphCollection.should_receive(:new).with(result, @api).and_return(expected) 141 | Koala::Facebook::GraphCollection.evaluate(result, @api).should == expected 142 | end 143 | end 144 | end -------------------------------------------------------------------------------- /spec/support/mock_http_service.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | require 'yaml' 3 | 4 | module Koala 5 | module MockHTTPService 6 | include Koala::HTTPService 7 | 8 | # fix our specs to use ok_json, so we always get the same results from to_json 9 | MultiJson.use :ok_json 10 | 11 | # Mocks all HTTP requests for with koala_spec_with_mocks.rb 12 | # Mocked values to be included in TEST_DATA used in specs 13 | ACCESS_TOKEN = '*' 14 | APP_ACCESS_TOKEN = "**" 15 | OAUTH_CODE = 'OAUTHCODE' 16 | 17 | # Loads testing data 18 | TEST_DATA = YAML.load_file(File.join(File.dirname(__FILE__), '..', 'fixtures', 'facebook_data.yml')) 19 | TEST_DATA.merge!('oauth_token' => Koala::MockHTTPService::ACCESS_TOKEN) 20 | TEST_DATA['oauth_test_data'].merge!('code' => Koala::MockHTTPService::OAUTH_CODE) 21 | TEST_DATA['search_time'] = (Time.now - 3600).to_s 22 | 23 | # Useful in mock_facebook_responses.yml 24 | OAUTH_DATA = TEST_DATA['oauth_test_data'] 25 | OAUTH_DATA.merge!({ 26 | 'app_access_token' => APP_ACCESS_TOKEN, 27 | 'session_key' => "session_key", 28 | 'multiple_session_keys' => ["session_key", "session_key_2"] 29 | }) 30 | APP_ID = OAUTH_DATA['app_id'] 31 | SECRET = OAUTH_DATA['secret'] 32 | SUBSCRIPTION_DATA = TEST_DATA["subscription_test_data"] 33 | 34 | # Loads the mock response data via ERB to substitue values for TEST_DATA (see oauth/access_token) 35 | mock_response_file_path = File.join(File.dirname(__FILE__), '..', 'fixtures', 'mock_facebook_responses.yml') 36 | RESPONSES = YAML.load(ERB.new(IO.read(mock_response_file_path)).result(binding)) 37 | 38 | def self.make_request(path, args, verb, options = {}) 39 | if response = match_response(path, args, verb, options) 40 | # create response class object 41 | response_object = if response.is_a? String 42 | Koala::HTTPService::Response.new(200, response, {}) 43 | else 44 | Koala::HTTPService::Response.new(response["code"] || 200, response["body"] || "", response["headers"] || {}) 45 | end 46 | else 47 | # Raises an error message with the place in the data YML 48 | # to place a mock as well as a URL to request from 49 | # Facebook's servers for the actual data 50 | # (Don't forget to replace ACCESS_TOKEN with a real access token) 51 | data_trace = [path, args, verb, options] * ': ' 52 | 53 | args = args == 'no_args' ? '' : "#{args}&" 54 | args += 'format=json' 55 | 56 | raise "Missing a mock response for #{data_trace}\nAPI PATH: #{[path, args].join('?')}" 57 | end 58 | 59 | response_object 60 | end 61 | 62 | def self.encode_params(*args) 63 | # use HTTPService's encode_params 64 | HTTPService.encode_params(*args) 65 | end 66 | 67 | protected 68 | 69 | # For a given query, see if our mock responses YAML has a resopnse for it. 70 | def self.match_response(path, args, verb, options = {}) 71 | server = options[:rest_api] ? 'rest_api' : 'graph_api' 72 | path = 'root' if path == '' || path == '/' 73 | verb = (verb || 'get').to_s 74 | token = args.delete('access_token') 75 | with_token = (token == ACCESS_TOKEN || token == APP_ACCESS_TOKEN) ? 'with_token' : 'no_token' 76 | 77 | if endpoint = RESPONSES[server][path] 78 | # see if we have a match for the arguments 79 | if arg_match = endpoint.find {|query, v| decode_query(query) == massage_args(args)} 80 | # we have a match for the server/path/arguments 81 | # so if it's the right verb and authentication, we're good 82 | # arg_match will be [query, hash_response] 83 | arg_match.last[verb][with_token] 84 | end 85 | end 86 | end 87 | 88 | # Since we're comparing the arguments with data in a yaml file, we need to 89 | # massage them slightly to get to the format we expect. 90 | def self.massage_args(arguments) 91 | args = arguments.inject({}) do |hash, (k, v)| 92 | # ensure our args are all stringified 93 | value = if v.is_a?(String) 94 | should_json_decode?(v) ? MultiJson.load(v) : v 95 | elsif v.is_a?(Koala::UploadableIO) 96 | # obviously there are no files in the yaml 97 | "[FILE]" 98 | else 99 | v 100 | end 101 | # make sure all keys are strings 102 | hash.merge(k.to_s => value) 103 | end 104 | 105 | # Assume format is always JSON 106 | args.delete('format') 107 | 108 | # if there are no args, return the special keyword no_args 109 | args.empty? ? "no_args" : args 110 | end 111 | 112 | # Facebook sometimes requires us to encode JSON values in an HTTP query 113 | # param. This complicates test matches, since we get into JSON-encoding 114 | # issues (what order keys are written into). To avoid string comparisons 115 | # and the hacks required to make it work, we decode the query into a 116 | # Ruby object. 117 | def self.decode_query(string) 118 | if string == "no_args" 119 | string 120 | else 121 | # we can't use Faraday's decode_query because that CGI-unencodes, which 122 | # will remove +'s in restriction strings 123 | string.split("&").reduce({}) do |hash, component| 124 | k, v = component.split("=", 2) # we only care about the first = 125 | value = should_json_decode?(v) ? MultiJson.decode(v) : v.to_s rescue v.to_s 126 | # some special-casing, unfortunate but acceptable in this testing 127 | # environment 128 | value = nil if value.empty? 129 | value = true if value == "true" 130 | value = false if value == "false" 131 | hash.merge(k => value) 132 | end 133 | end 134 | end 135 | 136 | # We want to decode JSON because it may not be encoded in the same order 137 | # all the time -- different Rubies may create a strings with equivalent 138 | # content but different order. We want to compare the objects. 139 | def self.should_json_decode?(v) 140 | v.match(/^[\[\{]/) 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/koala/http_service/uploadable_io.rb: -------------------------------------------------------------------------------- 1 | require "net/http/post/multipart" 2 | 3 | module Koala 4 | module HTTPService 5 | class UploadableIO 6 | attr_reader :io_or_path, :content_type, :filename 7 | 8 | def initialize(io_or_path_or_mixed, content_type = nil, filename = nil) 9 | # see if we got the right inputs 10 | parse_init_mixed_param io_or_path_or_mixed, content_type 11 | 12 | # filename is used in the Ads API 13 | # if it's provided, take precedence over the detected filename 14 | # otherwise, fall back to a dummy name 15 | @filename = filename || @filename || "koala-io-file.dum" 16 | 17 | raise KoalaError.new("Invalid arguments to initialize an UploadableIO") unless @io_or_path 18 | raise KoalaError.new("Unable to determine MIME type for UploadableIO") if !@content_type 19 | end 20 | 21 | def to_upload_io 22 | UploadIO.new(@io_or_path, @content_type, @filename) 23 | end 24 | 25 | def to_file 26 | @io_or_path.is_a?(String) ? File.open(@io_or_path) : @io_or_path 27 | end 28 | 29 | def self.binary_content?(content) 30 | content.is_a?(UploadableIO) || DETECTION_STRATEGIES.detect {|method| send(method, content)} 31 | end 32 | 33 | private 34 | DETECTION_STRATEGIES = [ 35 | :sinatra_param?, 36 | :rails_3_param?, 37 | :file_param? 38 | ] 39 | 40 | PARSE_STRATEGIES = [ 41 | :parse_rails_3_param, 42 | :parse_sinatra_param, 43 | :parse_file_object, 44 | :parse_string_path, 45 | :parse_io 46 | ] 47 | 48 | def parse_init_mixed_param(mixed, content_type = nil) 49 | PARSE_STRATEGIES.each do |method| 50 | send(method, mixed, content_type) 51 | return if @io_or_path && @content_type 52 | end 53 | end 54 | 55 | # Expects a parameter of type ActionDispatch::Http::UploadedFile 56 | def self.rails_3_param?(uploaded_file) 57 | uploaded_file.respond_to?(:content_type) and uploaded_file.respond_to?(:tempfile) and uploaded_file.tempfile.respond_to?(:path) 58 | end 59 | 60 | def parse_rails_3_param(uploaded_file, content_type = nil) 61 | if UploadableIO.rails_3_param?(uploaded_file) 62 | @io_or_path = uploaded_file.tempfile.path 63 | @content_type = content_type || uploaded_file.content_type 64 | @filename = uploaded_file.original_filename 65 | end 66 | end 67 | 68 | # Expects a Sinatra hash of file info 69 | def self.sinatra_param?(file_hash) 70 | file_hash.kind_of?(Hash) and file_hash.has_key?(:type) and file_hash.has_key?(:tempfile) 71 | end 72 | 73 | def parse_sinatra_param(file_hash, content_type = nil) 74 | if UploadableIO.sinatra_param?(file_hash) 75 | @io_or_path = file_hash[:tempfile] 76 | @content_type = content_type || file_hash[:type] || detect_mime_type(tempfile) 77 | @filename = file_hash[:filename] 78 | end 79 | end 80 | 81 | # takes a file object 82 | def self.file_param?(file) 83 | file.kind_of?(File) 84 | end 85 | 86 | def parse_file_object(file, content_type = nil) 87 | if UploadableIO.file_param?(file) 88 | @io_or_path = file 89 | @content_type = content_type || detect_mime_type(file.path) 90 | @filename = File.basename(file.path) 91 | end 92 | end 93 | 94 | def parse_string_path(path, content_type = nil) 95 | if path.kind_of?(String) 96 | @io_or_path = path 97 | @content_type = content_type || detect_mime_type(path) 98 | @filename = File.basename(path) 99 | end 100 | end 101 | 102 | def parse_io(io, content_type = nil) 103 | if io.respond_to?(:read) 104 | @io_or_path = io 105 | @content_type = content_type 106 | end 107 | end 108 | 109 | MIME_TYPE_STRATEGIES = [ 110 | :use_mime_module, 111 | :use_simple_detection 112 | ] 113 | 114 | def detect_mime_type(filename) 115 | if filename 116 | MIME_TYPE_STRATEGIES.each do |method| 117 | result = send(method, filename) 118 | return result if result 119 | end 120 | end 121 | nil # if we can't find anything 122 | end 123 | 124 | def use_mime_module(filename) 125 | # if the user has installed mime/types, we can use that 126 | # if not, rescue and return nil 127 | begin 128 | type = MIME::Types.type_for(filename).first 129 | type ? type.to_s : nil 130 | rescue 131 | nil 132 | end 133 | end 134 | 135 | def use_simple_detection(filename) 136 | # very rudimentary extension analysis for images 137 | # first, get the downcased extension, or an empty string if it doesn't exist 138 | extension = ((filename.match(/\.([a-zA-Z0-9]+)$/) || [])[1] || "").downcase 139 | case extension 140 | when "" 141 | nil 142 | # images 143 | when "jpg", "jpeg" 144 | "image/jpeg" 145 | when "png" 146 | "image/png" 147 | when "gif" 148 | "image/gif" 149 | 150 | # video 151 | when "3g2" 152 | "video/3gpp2" 153 | when "3gp", "3gpp" 154 | "video/3gpp" 155 | when "asf" 156 | "video/x-ms-asf" 157 | when "avi" 158 | "video/x-msvideo" 159 | when "flv" 160 | "video/x-flv" 161 | when "m4v" 162 | "video/x-m4v" 163 | when "mkv" 164 | "video/x-matroska" 165 | when "mod" 166 | "video/mod" 167 | when "mov", "qt" 168 | "video/quicktime" 169 | when "mp4", "mpeg4" 170 | "video/mp4" 171 | when "mpe", "mpeg", "mpg", "tod", "vob" 172 | "video/mpeg" 173 | when "nsv" 174 | "application/x-winamp" 175 | when "ogm", "ogv" 176 | "video/ogg" 177 | when "wmv" 178 | "video/x-ms-wmv" 179 | end 180 | end 181 | end 182 | end 183 | 184 | # @private 185 | # legacy support for when UploadableIO lived directly under Koala 186 | UploadableIO = HTTPService::UploadableIO 187 | end 188 | -------------------------------------------------------------------------------- /lib/koala/realtime_updates.rb: -------------------------------------------------------------------------------- 1 | module Koala 2 | module Facebook 3 | class RealtimeUpdates 4 | # Manage realtime callbacks for changes to users' information. 5 | # See http://developers.facebook.com/docs/reference/api/realtime. 6 | # 7 | # @note: to subscribe to real-time updates, you must have an application access token 8 | # or provide the app secret when initializing your RealtimeUpdates object. 9 | 10 | # The application API interface used to communicate with Facebook. 11 | # @return [Koala::Facebook::API] 12 | attr_reader :api 13 | attr_reader :app_id, :app_access_token, :secret 14 | 15 | # Create a new RealtimeUpdates instance. 16 | # If you don't have your app's access token, provide the app's secret and 17 | # Koala will make a request to Facebook for the appropriate token. 18 | # 19 | # @param options initialization options. 20 | # @option options :app_id the application's ID. 21 | # @option options :app_access_token an application access token, if known. 22 | # @option options :secret the application's secret. 23 | # 24 | # @raise ArgumentError if the application ID and one of the app access token or the secret are not provided. 25 | def initialize(options = {}) 26 | @app_id = options[:app_id] 27 | @app_access_token = options[:app_access_token] 28 | @secret = options[:secret] 29 | unless @app_id && (@app_access_token || @secret) # make sure we have what we need 30 | raise ArgumentError, "Initialize must receive a hash with :app_id and either :app_access_token or :secret! (received #{options.inspect})" 31 | end 32 | 33 | # fetch the access token if we're provided a secret 34 | if @secret && !@app_access_token 35 | oauth = Koala::Facebook::OAuth.new(@app_id, @secret) 36 | @app_access_token = oauth.get_app_access_token 37 | end 38 | 39 | @api = API.new(@app_access_token) 40 | end 41 | 42 | # Subscribe to realtime updates for certain fields on a given object (user, page, etc.). 43 | # See {http://developers.facebook.com/docs/reference/api/realtime the realtime updates documentation} 44 | # for more information on what objects and fields you can register for. 45 | # 46 | # @note Your callback_url must be set up to handle the verification request or the subscription will not be set up. 47 | # 48 | # @param object a Facebook ID (name or number) 49 | # @param fields the fields you want your app to be updated about 50 | # @param callback_url the URL Facebook should ping when an update is available 51 | # @param verify_token a token included in the verification request, allowing you to ensure the call is genuine 52 | # (see the docs for more information) 53 | # @param options (see Koala::HTTPService.make_request) 54 | # 55 | # @raise A subclass of Koala::Facebook::APIError if the subscription request failed. 56 | def subscribe(object, fields, callback_url, verify_token, options = {}) 57 | args = { 58 | :object => object, 59 | :fields => fields, 60 | :callback_url => callback_url, 61 | }.merge(verify_token ? {:verify_token => verify_token} : {}) 62 | # a subscription is a success if Facebook returns a 200 (after hitting your server for verification) 63 | @api.graph_call(subscription_path, args, 'post', options) 64 | end 65 | 66 | # Unsubscribe from updates for a particular object or from updates. 67 | # 68 | # @param object the object whose subscriptions to delete. 69 | # If no object is provided, all subscriptions will be removed. 70 | # @param options (see Koala::HTTPService.make_request) 71 | # 72 | # @raise A subclass of Koala::Facebook::APIError if the subscription request failed. 73 | def unsubscribe(object = nil, options = {}) 74 | @api.graph_call(subscription_path, object ? {:object => object} : {}, "delete", options) 75 | end 76 | 77 | # List all active subscriptions for this application. 78 | # 79 | # @param options (see Koala::HTTPService.make_request) 80 | # 81 | # @return [Array] a list of active subscriptions 82 | def list_subscriptions(options = {}) 83 | @api.graph_call(subscription_path, {}, "get", options) 84 | end 85 | 86 | # As a security measure (to prevent DDoS attacks), Facebook sends a verification request to your server 87 | # after you request a subscription. 88 | # This method parses the challenge params and makes sure the call is legitimate. 89 | # 90 | # @param params the request parameters sent by Facebook. (You can pass in a Rails params hash.) 91 | # @param verify_token the verify token sent in the {#subscribe subscription request}, if you provided one 92 | # 93 | # @yield verify_token if you need to compute the verification token 94 | # (for instance, if your callback URL includes a record ID, which you look up 95 | # and use to calculate a hash), you can pass meet_challenge a block, which 96 | # will receive the verify_token received back from Facebook. 97 | # 98 | # @return the challenge string to be sent back to Facebook, or false if the request is invalid. 99 | def self.meet_challenge(params, verify_token = nil, &verification_block) 100 | if params["hub.mode"] == "subscribe" && 101 | # you can make sure this is legitimate through two ways 102 | # if your store the token across the calls, you can pass in the token value 103 | # and we'll make sure it matches 104 | ((verify_token && params["hub.verify_token"] == verify_token) || 105 | # alternately, if you sent a specially-constructed value (such as a hash of various secret values) 106 | # you can pass in a block, which we'll call with the verify_token sent by Facebook 107 | # if it's legit, return anything that evaluates to true; otherwise, return nil or false 108 | (verification_block && yield(params["hub.verify_token"]))) 109 | params["hub.challenge"] 110 | else 111 | false 112 | end 113 | end 114 | 115 | # Public: As a security measure, all updates from facebook are signed using 116 | # X-Hub-Signature: sha1=XXXX where XXX is the sha1 of the json payload 117 | # using your application secret as the key. 118 | # 119 | # Example: 120 | # # in Rails controller 121 | # # @oauth being a previously defined Koala::Facebook::OAuth instance 122 | # def receive_update 123 | # if @oauth.validate_update(request.body, headers) 124 | # ... 125 | # end 126 | # end 127 | def validate_update(body, headers) 128 | if request_signature = headers['X-Hub-Signature'] || headers['HTTP_X_HUB_SIGNATURE'] and 129 | signature_parts = request_signature.split("sha1=") 130 | request_signature = signature_parts[1] 131 | calculated_signature = OpenSSL::HMAC.hexdigest('sha1', @secret, body) 132 | calculated_signature == request_signature 133 | end 134 | end 135 | 136 | # The Facebook subscription management URL for your application. 137 | def subscription_path 138 | @subscription_path ||= "#{@app_id}/subscriptions" 139 | end 140 | 141 | # @private 142 | def graph_api 143 | Koala::Utils.deprecate("the TestUsers.graph_api accessor is deprecated and will be removed in a future version; please use .api instead.") 144 | @api 145 | end 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /spec/support/koala_test.rb: -------------------------------------------------------------------------------- 1 | # small helper method for live testing 2 | module KoalaTest 3 | 4 | class << self 5 | attr_accessor :oauth_token, :app_id, :secret, :app_access_token, :code, :session_key 6 | attr_accessor :oauth_test_data, :subscription_test_data, :search_time 7 | attr_accessor :test_user_api 8 | end 9 | 10 | # Test setup 11 | 12 | def self.setup_test_environment! 13 | setup_rspec 14 | 15 | unless ENV['LIVE'] 16 | # By default the Koala specs are run using stubs for HTTP requests, 17 | # so they won't fail due to Facebook-imposed rate limits or server timeouts. 18 | # 19 | # However as a result they are more brittle since 20 | # we are not testing the latest responses from the Facebook servers. 21 | # To be certain all specs pass with the current Facebook services, 22 | # run LIVE=true bundle exec rake spec. 23 | Koala.http_service = Koala::MockHTTPService 24 | KoalaTest.setup_test_data(Koala::MockHTTPService::TEST_DATA) 25 | else 26 | # Runs Koala specs through the Facebook servers 27 | # using data for a real app 28 | live_data = YAML.load_file(File.join(File.dirname(__FILE__), '../fixtures/facebook_data.yml')) 29 | KoalaTest.setup_test_data(live_data) 30 | 31 | # allow live tests with different adapters 32 | adapter = ENV['ADAPTER'] || "typhoeus" # use Typhoeus by default if available 33 | begin 34 | require adapter 35 | require 'typhoeus/adapters/faraday' if adapter.to_s == "typhoeus" 36 | Faraday.default_adapter = adapter.to_sym 37 | rescue LoadError 38 | puts "Unable to load adapter #{adapter}, using Net::HTTP." 39 | end 40 | 41 | Koala.http_service.http_options[:beta] = true if ENV["beta"] || ENV["BETA"] 42 | 43 | # use a test user unless the developer wants to test against a real profile 44 | unless token = KoalaTest.oauth_token 45 | KoalaTest.setup_test_users 46 | else 47 | KoalaTest.validate_user_info(token) 48 | end 49 | end 50 | end 51 | 52 | def self.setup_rspec 53 | # set up a global before block to set the token for tests 54 | # set the token up for 55 | RSpec.configure do |config| 56 | config.before :each do 57 | @token = KoalaTest.oauth_token 58 | Koala::Utils.stub(:deprecate) # never fire deprecation warnings 59 | end 60 | 61 | config.after :each do 62 | # Clean up Koala config 63 | Koala.configure do |config| 64 | Koala::HTTPService::DEFAULT_SERVERS.each_pair do |k, v| 65 | config.send("#{k}=", v) 66 | end 67 | end 68 | 69 | # if we're working with a real user, clean up any objects posted to Facebook 70 | # no need to do so for test users, since they get deleted at the end 71 | if @temporary_object_id && KoalaTest.real_user? 72 | raise "Unable to locate API when passed temporary object to delete!" unless @api 73 | 74 | # wait 10ms to allow Facebook to propagate data so we can delete it 75 | sleep(0.01) 76 | 77 | # clean up any objects we've posted 78 | result = (@api.delete_object(@temporary_object_id) rescue false) 79 | # if we errored out or Facebook returned false, track that 80 | puts "Unable to delete #{@temporary_object_id}: #{result} (probably a photo or video, which can't be deleted through the API)" unless result 81 | end 82 | 83 | end 84 | end 85 | end 86 | 87 | def self.setup_test_data(data) 88 | # make data accessible to all our tests 89 | self.oauth_test_data = data["oauth_test_data"] 90 | self.subscription_test_data = data["subscription_test_data"] 91 | self.oauth_token = data["oauth_token"] 92 | self.app_id = data["oauth_test_data"]["app_id"].to_s 93 | self.app_access_token = data["oauth_test_data"]["app_access_token"] 94 | self.secret = data["oauth_test_data"]["secret"] 95 | self.code = data["oauth_test_data"]["code"] 96 | self.session_key = data["oauth_test_data"]["session_key"] 97 | 98 | # fix the search time so it can be used in the mock responses 99 | self.search_time = data["search_time"] || (Time.now - 3600).to_s 100 | end 101 | 102 | def self.testing_permissions 103 | "read_stream, publish_stream, user_photos, user_videos, read_insights" 104 | end 105 | 106 | def self.setup_test_users 107 | print "Setting up test users..." 108 | @test_user_api = Koala::Facebook::TestUsers.new(:app_id => self.app_id, :secret => self.secret) 109 | 110 | RSpec.configure do |config| 111 | config.before :suite do 112 | # before each test module, create two test users with specific names and befriend them 113 | KoalaTest.create_test_users 114 | end 115 | 116 | config.after :suite do 117 | # after each test module, delete the test users to avoid cluttering up the application 118 | KoalaTest.destroy_test_users 119 | end 120 | end 121 | 122 | puts "done." 123 | end 124 | 125 | def self.create_test_users 126 | begin 127 | @live_testing_user = @test_user_api.create(true, KoalaTest.testing_permissions, :name => KoalaTest.user1_name) 128 | @live_testing_friend = @test_user_api.create(true, KoalaTest.testing_permissions, :name => KoalaTest.user2_name) 129 | @test_user_api.befriend(@live_testing_user, @live_testing_friend) 130 | self.oauth_token = @live_testing_user["access_token"] 131 | rescue Exception => e 132 | Kernel.warn("Problem creating test users! #{e.message}") 133 | raise 134 | end 135 | end 136 | 137 | def self.destroy_test_users 138 | [@live_testing_user, @live_testing_friend].each do |u| 139 | puts "Unable to delete test user #{u.inspect}" if u && !(@test_user_api.delete(u) rescue false) 140 | end 141 | end 142 | 143 | def self.validate_user_info(token) 144 | print "Validating permissions for live testing..." 145 | # make sure we have the necessary permissions 146 | api = Koala::Facebook::API.new(token) 147 | perms = api.fql_query("select #{testing_permissions} from permissions where uid = me()")[0] 148 | perms.each_pair do |perm, value| 149 | if value == (perm == "read_insights" ? 1 : 0) # live testing depends on insights calls failing 150 | puts "failed!\n" # put a new line after the print above 151 | raise ArgumentError, "Your access token must have the read_stream, publish_stream, and user_photos permissions, and lack read_insights. You have: #{perms.inspect}" 152 | end 153 | end 154 | puts "done!" 155 | end 156 | 157 | # Info about the testing environment 158 | def self.real_user? 159 | !(mock_interface? || @test_user_api) 160 | end 161 | 162 | def self.test_user? 163 | !!@test_user_api 164 | end 165 | 166 | def self.mock_interface? 167 | Koala.http_service == Koala::MockHTTPService 168 | end 169 | 170 | # Data for testing 171 | def self.user1 172 | # user ID, either numeric or username 173 | test_user? ? @live_testing_user["id"] : "koppel" 174 | end 175 | 176 | def self.user1_id 177 | # numerical ID, used for FQL 178 | # (otherwise the two IDs are interchangeable) 179 | test_user? ? @live_testing_user["id"] : 2905623 180 | end 181 | 182 | def self.user1_name 183 | "Alex" 184 | end 185 | 186 | def self.user2 187 | # see notes for user1 188 | test_user? ? @live_testing_friend["id"] : "lukeshepard" 189 | end 190 | 191 | def self.user2_id 192 | # see notes for user1 193 | test_user? ? @live_testing_friend["id"] : 2901279 194 | end 195 | 196 | def self.user2_name 197 | "Luke" 198 | end 199 | 200 | def self.page 201 | "facebook" 202 | end 203 | 204 | def self.app_properties 205 | mock_interface? ? {"desktop" => 0} : {"description" => "A test framework for Koala and its users. (#{rand(10000).to_i})"} 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /spec/cases/uploadable_io_spec.rb: -------------------------------------------------------------------------------- 1 | # fake MIME::Types 2 | module Koala::MIME 3 | module Types 4 | def self.type_for(type) 5 | # this should be faked out in tests 6 | nil 7 | end 8 | end 9 | end 10 | 11 | describe "Koala::UploadableIO" do 12 | def rails_3_mocks 13 | tempfile = stub('Tempfile', :path => "foo") 14 | uploaded_file = stub('ActionDispatch::Http::UploadedFile', 15 | :content_type => true, 16 | :tempfile => tempfile, 17 | :original_filename => "bar" 18 | ) 19 | tempfile.stub!(:respond_to?).with(:path).and_return(true) 20 | 21 | [tempfile, uploaded_file] 22 | end 23 | 24 | def sinatra_mocks 25 | {:type => "type", :tempfile => "Tempfile", :filename => "foo.bar"} 26 | end 27 | 28 | describe "the constructor" do 29 | describe "when given a file path" do 30 | before(:each) do 31 | @path = BEACH_BALL_PATH 32 | @koala_io_params = [@path] 33 | end 34 | 35 | describe "and a content type" do 36 | before :each do 37 | @stub_type = stub("image/jpg") 38 | @koala_io_params.concat([@stub_type]) 39 | end 40 | 41 | it "returns an UploadIO with the same file path" do 42 | Koala::UploadableIO.new(*@koala_io_params).io_or_path.should == @path 43 | end 44 | 45 | it "returns an UploadIO with the same content type" do 46 | Koala::UploadableIO.new(*@koala_io_params).content_type.should == @stub_type 47 | end 48 | 49 | it "returns an UploadIO with the file's name" do 50 | Koala::UploadableIO.new(*@koala_io_params).filename.should == File.basename(@path) 51 | end 52 | end 53 | 54 | describe "and no content type" do 55 | it_should_behave_like "determining a mime type" 56 | end 57 | end 58 | 59 | describe "when given a File object" do 60 | before(:each) do 61 | @file = File.open(BEACH_BALL_PATH) 62 | @koala_io_params = [@file] 63 | end 64 | 65 | describe "and a content type" do 66 | before :each do 67 | @koala_io_params.concat(["image/jpg"]) 68 | end 69 | 70 | it "returns an UploadIO with the same io" do 71 | Koala::UploadableIO.new(*@koala_io_params).io_or_path.should == @koala_io_params[0] 72 | end 73 | 74 | it "returns an UploadableIO with the same content_type" do 75 | content_stub = @koala_io_params[1] = stub('Content Type') 76 | Koala::UploadableIO.new(*@koala_io_params).content_type.should == content_stub 77 | end 78 | 79 | it "returns an UploadableIO with the right filename" do 80 | Koala::UploadableIO.new(*@koala_io_params).filename.should == File.basename(@file.path) 81 | end 82 | end 83 | 84 | describe "and no content type" do 85 | it_should_behave_like "determining a mime type" 86 | end 87 | end 88 | 89 | describe "when given an IO object" do 90 | before(:each) do 91 | @io = StringIO.open("abcdefgh") 92 | @koala_io_params = [@io] 93 | end 94 | 95 | describe "and a content type" do 96 | before :each do 97 | @koala_io_params.concat(["image/jpg"]) 98 | end 99 | 100 | it "returns an UploadableIO with the same io" do 101 | Koala::UploadableIO.new(*@koala_io_params).io_or_path.should == @koala_io_params[0] 102 | end 103 | 104 | it "returns an UploadableIO with the same content_type" do 105 | content_stub = @koala_io_params[1] = stub('Content Type') 106 | Koala::UploadableIO.new(*@koala_io_params).content_type.should == content_stub 107 | end 108 | end 109 | 110 | describe "and no content type" do 111 | it "raises an exception" do 112 | lambda { Koala::UploadableIO.new(*@koala_io_params) }.should raise_exception(Koala::KoalaError) 113 | end 114 | end 115 | end 116 | 117 | describe "when given a Rails 3 ActionDispatch::Http::UploadedFile" do 118 | before(:each) do 119 | @tempfile, @uploaded_file = rails_3_mocks 120 | end 121 | 122 | it "gets the path from the tempfile associated with the UploadedFile" do 123 | expected_path = stub('Tempfile') 124 | @tempfile.should_receive(:path).and_return(expected_path) 125 | Koala::UploadableIO.new(@uploaded_file).io_or_path.should == expected_path 126 | end 127 | 128 | it "gets the content type via the content_type method" do 129 | expected_content_type = stub('Content Type') 130 | @uploaded_file.should_receive(:content_type).and_return(expected_content_type) 131 | Koala::UploadableIO.new(@uploaded_file).content_type.should == expected_content_type 132 | end 133 | 134 | it "gets the filename from the UploadedFile" do 135 | Koala::UploadableIO.new(@uploaded_file).filename.should == @uploaded_file.original_filename 136 | end 137 | end 138 | 139 | describe "when given a Sinatra file parameter hash" do 140 | before(:each) do 141 | @file_hash = sinatra_mocks 142 | end 143 | 144 | it "gets the io_or_path from the :tempfile key" do 145 | expected_file = stub('File') 146 | @file_hash[:tempfile] = expected_file 147 | 148 | uploadable = Koala::UploadableIO.new(@file_hash) 149 | uploadable.io_or_path.should == expected_file 150 | end 151 | 152 | it "gets the content type from the :type key" do 153 | expected_content_type = stub('Content Type') 154 | @file_hash[:type] = expected_content_type 155 | 156 | uploadable = Koala::UploadableIO.new(@file_hash) 157 | uploadable.content_type.should == expected_content_type 158 | end 159 | 160 | it "gets the content type from the :type key" do 161 | uploadable = Koala::UploadableIO.new(@file_hash) 162 | uploadable.filename.should == @file_hash[:filename] 163 | end 164 | end 165 | 166 | describe "for files with with recognizable MIME types" do 167 | # what that means is tested below 168 | it "should accept a file object alone" do 169 | params = [BEACH_BALL_PATH] 170 | lambda { Koala::UploadableIO.new(*params) }.should_not raise_exception(Koala::KoalaError) 171 | end 172 | 173 | it "should accept a file path alone" do 174 | params = [BEACH_BALL_PATH] 175 | lambda { Koala::UploadableIO.new(*params) }.should_not raise_exception(Koala::KoalaError) 176 | end 177 | end 178 | end 179 | 180 | describe "getting an UploadableIO" do 181 | before(:each) do 182 | @upload_io = stub("UploadIO") 183 | UploadIO.stub!(:new).with(anything, anything, anything).and_return(@upload_io) 184 | end 185 | 186 | context "if no filename was provided" do 187 | it "should call the constructor with the content type, file name, and a dummy file name" do 188 | UploadIO.should_receive(:new).with(BEACH_BALL_PATH, "content/type", anything).and_return(@upload_io) 189 | Koala::UploadableIO.new(BEACH_BALL_PATH, "content/type").to_upload_io.should == @upload_io 190 | end 191 | end 192 | 193 | context "if a filename was provided" do 194 | it "should call the constructor with the content type, file name, and the filename" do 195 | filename = "file" 196 | UploadIO.should_receive(:new).with(BEACH_BALL_PATH, "content/type", filename).and_return(@upload_io) 197 | Koala::UploadableIO.new(BEACH_BALL_PATH, "content/type", filename).to_upload_io 198 | end 199 | end 200 | end 201 | 202 | describe "getting a file" do 203 | it "returns the File if initialized with a file" do 204 | f = File.new(BEACH_BALL_PATH) 205 | Koala::UploadableIO.new(f).to_file.should == f 206 | end 207 | 208 | it "should open up and return a file corresponding to the path if io_or_path is a path" do 209 | result = stub("File") 210 | File.should_receive(:open).with(BEACH_BALL_PATH).and_return(result) 211 | Koala::UploadableIO.new(BEACH_BALL_PATH).to_file.should == result 212 | end 213 | end 214 | 215 | describe ".binary_content?" do 216 | it "returns true for Rails 3 file uploads" do 217 | Koala::UploadableIO.binary_content?(rails_3_mocks.last).should be_true 218 | end 219 | 220 | it "returns true for Sinatra file uploads" do 221 | Koala::UploadableIO.binary_content?(rails_3_mocks.last).should be_true 222 | end 223 | 224 | it "returns true for File objects" do 225 | Koala::UploadableIO.binary_content?(File.open(BEACH_BALL_PATH)).should be_true 226 | end 227 | 228 | it "returns false for everything else" do 229 | Koala::UploadableIO.binary_content?(StringIO.new).should be_false 230 | Koala::UploadableIO.binary_content?(BEACH_BALL_PATH).should be_false 231 | Koala::UploadableIO.binary_content?(nil).should be_false 232 | end 233 | end 234 | end # describe UploadableIO 235 | -------------------------------------------------------------------------------- /lib/koala/test_users.rb: -------------------------------------------------------------------------------- 1 | require 'koala' 2 | 3 | module Koala 4 | module Facebook 5 | 6 | # Create and manage test users for your application. 7 | # A test user is a user account associated with an app created for the purpose 8 | # of testing the functionality of that app. 9 | # You can use test users for manual or automated testing -- 10 | # Koala's live test suite uses test users to verify the library works with Facebook. 11 | # 12 | # @note the test user API is fairly slow compared to other interfaces 13 | # (which makes sense -- it's creating whole new user accounts!). 14 | # 15 | # See http://developers.facebook.com/docs/test_users/. 16 | class TestUsers 17 | 18 | # The application API interface used to communicate with Facebook. 19 | # @return [Koala::Facebook::API] 20 | attr_reader :api 21 | attr_reader :app_id, :app_access_token, :secret 22 | 23 | # Create a new TestUsers instance. 24 | # If you don't have your app's access token, provide the app's secret and 25 | # Koala will make a request to Facebook for the appropriate token. 26 | # 27 | # @param options initialization options. 28 | # @option options :app_id the application's ID. 29 | # @option options :app_access_token an application access token, if known. 30 | # @option options :secret the application's secret. 31 | # 32 | # @raise ArgumentError if the application ID and one of the app access token or the secret are not provided. 33 | def initialize(options = {}) 34 | @app_id = options[:app_id] 35 | @app_access_token = options[:app_access_token] 36 | @secret = options[:secret] 37 | unless @app_id && (@app_access_token || @secret) # make sure we have what we need 38 | raise ArgumentError, "Initialize must receive a hash with :app_id and either :app_access_token or :secret! (received #{options.inspect})" 39 | end 40 | 41 | # fetch the access token if we're provided a secret 42 | if @secret && !@app_access_token 43 | oauth = Koala::Facebook::OAuth.new(@app_id, @secret) 44 | @app_access_token = oauth.get_app_access_token 45 | end 46 | 47 | @api = API.new(@app_access_token) 48 | end 49 | 50 | # Create a new test user. 51 | # 52 | # @param installed whether the user has installed your app 53 | # @param permissions a comma-separated string or array of permissions the user has granted (if installed) 54 | # @param args any additional arguments for the create call (name, etc.) 55 | # @param options (see Koala::Facebook::API#api) 56 | # 57 | # @return a hash of information for the new user (id, access token, login URL, etc.) 58 | def create(installed, permissions = nil, args = {}, options = {}) 59 | # Creates and returns a test user 60 | args['installed'] = installed 61 | args['permissions'] = (permissions.is_a?(Array) ? permissions.join(",") : permissions) if installed 62 | @api.graph_call(test_user_accounts_path, args, "post", options) 63 | end 64 | 65 | # List all test users for the app. 66 | # 67 | # @param options (see Koala::Facebook::API#api) 68 | # 69 | # @return an array of hashes of user information (id, access token, etc.) 70 | def list(options = {}) 71 | @api.graph_call(test_user_accounts_path, {}, "get", options) 72 | end 73 | 74 | # Delete a test user. 75 | # 76 | # @param test_user the user to delete; can be either a Facebook ID or the hash returned by {#create} 77 | # @param options (see Koala::Facebook::API#api) 78 | # 79 | # @return true if successful, false (or an {Koala::Facebook::APIError APIError}) if not 80 | def delete(test_user, options = {}) 81 | test_user = test_user["id"] if test_user.is_a?(Hash) 82 | @api.delete_object(test_user, options) 83 | end 84 | 85 | # Deletes all test users in batches of 50. 86 | # 87 | # @note if you have a lot of test users (> 20), this operation can take a long time. 88 | # 89 | # @param options (see Koala::Facebook::API#api) 90 | # 91 | # @return a list of the test users that have been deleted 92 | def delete_all(options = {}) 93 | # ideally we'd save a call by checking next_page_params, but at the time of writing 94 | # Facebook isn't consistently returning full pages after the first one 95 | previous_list = nil 96 | while (test_user_list = list(options)).length > 0 97 | # avoid infinite loops if Facebook returns buggy users you can't delete 98 | # see http://developers.facebook.com/bugs/223629371047398 99 | break if test_user_list == previous_list 100 | 101 | test_user_list.each_slice(50) do |users| 102 | self.api.batch(options) {|batch_api| users.each {|u| batch_api.delete_object(u["id"]) }} 103 | end 104 | 105 | previous_list = test_user_list 106 | end 107 | end 108 | 109 | # Updates a test user's attributes. 110 | # 111 | # @note currently, only name and password can be changed; 112 | # see {http://developers.facebook.com/docs/test_users/ the Facebook documentation}. 113 | # 114 | # @param test_user the user to update; can be either a Facebook ID or the hash returned by {#create} 115 | # @param args the updates to make 116 | # @param options (see Koala::Facebook::API#api) 117 | # 118 | # @return true if successful, false (or an {Koala::Facebook::APIError APIError}) if not 119 | def update(test_user, args = {}, options = {}) 120 | test_user = test_user["id"] if test_user.is_a?(Hash) 121 | @api.graph_call(test_user, args, "post", options) 122 | end 123 | 124 | # Make two test users friends. 125 | # 126 | # @note there's no way to unfriend test users; you can always just create a new one. 127 | # 128 | # @param user1_hash one of the users to friend; the hash must contain both ID and access token (as returned by {#create}) 129 | # @param user2_hash the other user to friend 130 | # @param options (see Koala::Facebook::API#api) 131 | # 132 | # @return true if successful, false (or an {Koala::Facebook::APIError APIError}) if not 133 | def befriend(user1_hash, user2_hash, options = {}) 134 | user1_id = user1_hash["id"] || user1_hash[:id] 135 | user2_id = user2_hash["id"] || user2_hash[:id] 136 | user1_token = user1_hash["access_token"] || user1_hash[:access_token] 137 | user2_token = user2_hash["access_token"] || user2_hash[:access_token] 138 | unless user1_id && user2_id && user1_token && user2_token 139 | # we explicitly raise an error here to minimize the risk of confusing output 140 | # if you pass in a string (as was previously supported) no local exception would be raised 141 | # but the Facebook call would fail 142 | raise ArgumentError, "TestUsers#befriend requires hash arguments for both users with id and access_token" 143 | end 144 | 145 | u1_graph_api = API.new(user1_token) 146 | u2_graph_api = API.new(user2_token) 147 | 148 | u1_graph_api.graph_call("#{user1_id}/friends/#{user2_id}", {}, "post", options) && 149 | u2_graph_api.graph_call("#{user2_id}/friends/#{user1_id}", {}, "post", options) 150 | end 151 | 152 | # Create a network of test users, all of whom are friends and have the same permissions. 153 | # 154 | # @note this call slows down dramatically the more users you create 155 | # (test user calls are slow, and more users => more 1-on-1 connections to be made). 156 | # Use carefully. 157 | # 158 | # @param network_size how many users to create 159 | # @param installed whether the users have installed your app (see {#create}) 160 | # @param permissions what permissions the users have granted (see {#create}) 161 | # @param options (see Koala::Facebook::API#api) 162 | # 163 | # @return the list of users created 164 | def create_network(network_size, installed = true, permissions = '', options = {}) 165 | users = (0...network_size).collect { create(installed, permissions, options) } 166 | friends = users.clone 167 | users.each do |user| 168 | # Remove this user from list of friends 169 | friends.delete_at(0) 170 | # befriend all the others 171 | friends.each do |friend| 172 | befriend(user, friend, options) 173 | end 174 | end 175 | return users 176 | end 177 | 178 | # The Facebook test users management URL for your application. 179 | def test_user_accounts_path 180 | @test_user_accounts_path ||= "/#{@app_id}/accounts/test-users" 181 | end 182 | 183 | # @private 184 | # Legacy accessor for before GraphAPI was unified into API 185 | def graph_api 186 | Koala::Utils.deprecate("the TestUsers.graph_api accessor is deprecated and will be removed in a future version; please use .api instead.") 187 | @api 188 | end 189 | end # TestUserMethods 190 | end # Facebook 191 | end # Koala 192 | -------------------------------------------------------------------------------- /lib/koala/http_service.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'koala/http_service/multipart_request' 3 | require 'koala/http_service/uploadable_io' 4 | require 'koala/http_service/response' 5 | 6 | module Koala 7 | module HTTPService 8 | class << self 9 | # A customized stack of Faraday middleware that will be used to make each request. 10 | attr_accessor :faraday_middleware 11 | # A default set of HTTP options (see https://github.com/arsduo/koala/wiki/HTTP-Services) 12 | attr_accessor :http_options 13 | end 14 | 15 | @http_options ||= {} 16 | 17 | # Koala's default middleware stack. 18 | # We encode requests in a Facebook-compatible multipart request, 19 | # and use whichever adapter has been configured for this application. 20 | DEFAULT_MIDDLEWARE = Proc.new do |builder| 21 | builder.use Koala::HTTPService::MultipartRequest 22 | builder.request :url_encoded 23 | builder.adapter Faraday.default_adapter 24 | end 25 | 26 | # Default servers for Facebook. These are read into the config OpenStruct, 27 | # and can be overridden via Koala.config. 28 | DEFAULT_SERVERS = { 29 | :graph_server => 'graph.facebook.com', 30 | :dialog_host => 'www.facebook.com', 31 | :rest_server => 'api.facebook.com', 32 | # certain Facebook services (beta, video) require you to access different 33 | # servers. If you're using your own servers, for instance, for a proxy, 34 | # you can change both the matcher and the replacement values. 35 | # So for instance, if you're talking to fbproxy.mycompany.com, you could 36 | # set up beta.fbproxy.mycompany.com for FB's beta tier, and set the 37 | # matcher to /\.fbproxy/ and the beta_replace to '.beta.fbproxy'. 38 | :host_path_matcher => /\.facebook/, 39 | :video_replace => '-video.facebook', 40 | :beta_replace => '.beta.facebook' 41 | } 42 | 43 | # The address of the appropriate Facebook server. 44 | # 45 | # @param options various flags to indicate which server to use. 46 | # @option options :rest_api use the old REST API instead of the Graph API 47 | # @option options :video use the server designated for video uploads 48 | # @option options :beta use the beta tier 49 | # @option options :use_ssl force https, even if not needed 50 | # 51 | # @return a complete server address with protocol 52 | def self.server(options = {}) 53 | server = "#{options[:rest_api] ? Koala.config.rest_server : Koala.config.graph_server}" 54 | server.gsub!(Koala.config.host_path_matcher, Koala.config.video_replace) if options[:video] 55 | server.gsub!(Koala.config.host_path_matcher, Koala.config.beta_replace) if options[:beta] 56 | "#{options[:use_ssl] ? "https" : "http"}://#{server}" 57 | end 58 | 59 | # Makes a request directly to Facebook. 60 | # @note You'll rarely need to call this method directly. 61 | # 62 | # @see Koala::Facebook::API#api 63 | # @see Koala::Facebook::GraphAPIMethods#graph_call 64 | # @see Koala::Facebook::RestAPIMethods#rest_call 65 | # 66 | # @param path the server path for this request 67 | # @param args (see Koala::Facebook::API#api) 68 | # @param verb the HTTP method to use. 69 | # If not get or post, this will be turned into a POST request with the appropriate :method 70 | # specified in the arguments. 71 | # @param options (see Koala::Facebook::API#api) 72 | # 73 | # @raise an appropriate connection error if unable to make the request to Facebook 74 | # 75 | # @return [Koala::HTTPService::Response] a response object representing the results from Facebook 76 | def self.make_request(path, args, verb, options = {}) 77 | # if the verb isn't get or post, send it as a post argument 78 | args.merge!({:method => verb}) && verb = "post" if verb != "get" && verb != "post" 79 | 80 | # turn all the keys to strings (Faraday has issues with symbols under 1.8.7) and resolve UploadableIOs 81 | params = args.inject({}) {|hash, kv| hash[kv.first.to_s] = kv.last.is_a?(UploadableIO) ? kv.last.to_upload_io : kv.last; hash} 82 | 83 | # figure out our options for this request 84 | request_options = {:params => (verb == "get" ? params : {})}.merge(http_options || {}).merge(process_options(options)) 85 | request_options[:use_ssl] = true if args["access_token"] # require https if there's a token 86 | if request_options[:use_ssl] 87 | ssl = (request_options[:ssl] ||= {}) 88 | ssl[:verify] = true unless ssl.has_key?(:verify) 89 | end 90 | 91 | # set up our Faraday connection 92 | # we have to manually assign params to the URL or the 93 | conn = Faraday.new(server(request_options), request_options, &(faraday_middleware || DEFAULT_MIDDLEWARE)) 94 | 95 | response = conn.send(verb, path, (verb == "post" ? params : {})) 96 | 97 | # Log URL information 98 | Koala::Utils.debug "#{verb.upcase}: #{path} params: #{params.inspect}" 99 | Koala::HTTPService::Response.new(response.status.to_i, response.body, response.headers) 100 | end 101 | 102 | # Encodes a given hash into a query string. 103 | # This is used mainly by the Batch API nowadays, since Faraday handles this for regular cases. 104 | # 105 | # @param params_hash a hash of values to CGI-encode and appropriately join 106 | # 107 | # @example 108 | # Koala.http_service.encode_params({:a => 2, :b => "My String"}) 109 | # => "a=2&b=My+String" 110 | # 111 | # @return the appropriately-encoded string 112 | def self.encode_params(param_hash) 113 | ((param_hash || {}).sort_by{|k, v| k.to_s}.collect do |key_and_value| 114 | key_and_value[1] = MultiJson.dump(key_and_value[1]) unless key_and_value[1].is_a? String 115 | "#{key_and_value[0].to_s}=#{CGI.escape key_and_value[1]}" 116 | end).join("&") 117 | end 118 | 119 | # deprecations 120 | # not elegant or compact code, but temporary 121 | 122 | # @private 123 | def self.always_use_ssl 124 | Koala::Utils.deprecate("HTTPService.always_use_ssl is now HTTPService.http_options[:use_ssl]; always_use_ssl will be removed in a future version.") 125 | http_options[:use_ssl] 126 | end 127 | 128 | # @private 129 | def self.always_use_ssl=(value) 130 | Koala::Utils.deprecate("HTTPService.always_use_ssl is now HTTPService.http_options[:use_ssl]; always_use_ssl will be removed in a future version.") 131 | http_options[:use_ssl] = value 132 | end 133 | 134 | # @private 135 | def self.timeout 136 | Koala::Utils.deprecate("HTTPService.timeout is now HTTPService.http_options[:timeout]; .timeout will be removed in a future version.") 137 | http_options[:timeout] 138 | end 139 | 140 | # @private 141 | def self.timeout=(value) 142 | Koala::Utils.deprecate("HTTPService.timeout is now HTTPService.http_options[:timeout]; .timeout will be removed in a future version.") 143 | http_options[:timeout] = value 144 | end 145 | 146 | # @private 147 | def self.proxy 148 | Koala::Utils.deprecate("HTTPService.proxy is now HTTPService.http_options[:proxy]; .proxy will be removed in a future version.") 149 | http_options[:proxy] 150 | end 151 | 152 | # @private 153 | def self.proxy=(value) 154 | Koala::Utils.deprecate("HTTPService.proxy is now HTTPService.http_options[:proxy]; .proxy will be removed in a future version.") 155 | http_options[:proxy] = value 156 | end 157 | 158 | # @private 159 | def self.ca_path 160 | Koala::Utils.deprecate("HTTPService.ca_path is now (HTTPService.http_options[:ssl] ||= {})[:ca_path]; .ca_path will be removed in a future version.") 161 | (http_options[:ssl] || {})[:ca_path] 162 | end 163 | 164 | # @private 165 | def self.ca_path=(value) 166 | Koala::Utils.deprecate("HTTPService.ca_path is now (HTTPService.http_options[:ssl] ||= {})[:ca_path]; .ca_path will be removed in a future version.") 167 | (http_options[:ssl] ||= {})[:ca_path] = value 168 | end 169 | 170 | # @private 171 | def self.ca_file 172 | Koala::Utils.deprecate("HTTPService.ca_file is now (HTTPService.http_options[:ssl] ||= {})[:ca_file]; .ca_file will be removed in a future version.") 173 | (http_options[:ssl] || {})[:ca_file] 174 | end 175 | 176 | # @private 177 | def self.ca_file=(value) 178 | Koala::Utils.deprecate("HTTPService.ca_file is now (HTTPService.http_options[:ssl] ||= {})[:ca_file]; .ca_file will be removed in a future version.") 179 | (http_options[:ssl] ||= {})[:ca_file] = value 180 | end 181 | 182 | # @private 183 | def self.verify_mode 184 | Koala::Utils.deprecate("HTTPService.verify_mode is now (HTTPService.http_options[:ssl] ||= {})[:verify_mode]; .verify_mode will be removed in a future version.") 185 | (http_options[:ssl] || {})[:verify_mode] 186 | end 187 | 188 | # @private 189 | def self.verify_mode=(value) 190 | Koala::Utils.deprecate("HTTPService.verify_mode is now (HTTPService.http_options[:ssl] ||= {})[:verify_mode]; .verify_mode will be removed in a future version.") 191 | (http_options[:ssl] ||= {})[:verify_mode] = value 192 | end 193 | 194 | private 195 | 196 | def self.process_options(options) 197 | if typhoeus_options = options.delete(:typhoeus_options) 198 | Koala::Utils.deprecate("typhoeus_options should now be included directly in the http_options hash. Support for this key will be removed in a future version.") 199 | options = options.merge(typhoeus_options) 200 | end 201 | 202 | if ca_file = options.delete(:ca_file) 203 | Koala::Utils.deprecate("http_options[:ca_file] should now be passed inside (http_options[:ssl] = {}) -- that is, http_options[:ssl][:ca_file]. Support for this key will be removed in a future version.") 204 | (options[:ssl] ||= {})[:ca_file] = ca_file 205 | end 206 | 207 | if ca_path = options.delete(:ca_path) 208 | Koala::Utils.deprecate("http_options[:ca_path] should now be passed inside (http_options[:ssl] = {}) -- that is, http_options[:ssl][:ca_path]. Support for this key will be removed in a future version.") 209 | (options[:ssl] ||= {})[:ca_path] = ca_path 210 | end 211 | 212 | if verify_mode = options.delete(:verify_mode) 213 | Koala::Utils.deprecate("http_options[:verify_mode] should now be passed inside (http_options[:ssl] = {}) -- that is, http_options[:ssl][:verify_mode]. Support for this key will be removed in a future version.") 214 | (options[:ssl] ||= {})[:verify_mode] = verify_mode 215 | end 216 | 217 | options 218 | end 219 | end 220 | 221 | # @private 222 | module TyphoeusService 223 | def self.deprecated_interface 224 | # support old-style interface with a warning 225 | Koala::Utils.deprecate("the TyphoeusService module is deprecated; to use Typhoeus, set Faraday.default_adapter = :typhoeus. Enabling Typhoeus for all Faraday connections.") 226 | Faraday.default_adapter = :typhoeus 227 | end 228 | end 229 | 230 | # @private 231 | module NetHTTPService 232 | def self.deprecated_interface 233 | # support old-style interface with a warning 234 | Koala::Utils.deprecate("the NetHTTPService module is deprecated; to use Net::HTTP, set Faraday.default_adapter = :net_http. Enabling Net::HTTP for all Faraday connections.") 235 | Faraday.default_adapter = :net_http 236 | end 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://secure.travis-ci.org/arsduo/koala.png)](http://travis-ci.org/arsduo/koala) 2 | 3 | **Note**: a recent Facebook change will cause apps that parse the cookies every 4 | request to fail with the error "OAuthException: This authorization code has 5 | been used." If you're seeing this, please read the note in the [OAuth 6 | wiki](https://github.com/arsduo/koala/wiki/OAuth) for more information. 7 | 8 | Koala 9 | ==== 10 | [Koala](http://github.com/arsduo/koala) is a Facebook library for Ruby, supporting the Graph API (including the batch requests and photo uploads), the REST API, realtime updates, test users, and OAuth validation. We wrote Koala with four goals: 11 | 12 | * Lightweight: Koala should be as light and simple as Facebook’s own libraries, providing API accessors and returning simple JSON. 13 | * Fast: Koala should, out of the box, be quick. Out of the box, we use Facebook's faster read-only servers when possible and if available, the Typhoeus gem to make snappy Facebook requests. Of course, that brings us to our next topic: 14 | * Flexible: Koala should be useful to everyone, regardless of their current configuration. We support JRuby, Rubinius, and REE as well as vanilla Ruby (1.8.7, 1.9.2, 1.9.3, and 2.0.0), and use the Faraday library to provide complete flexibility over how HTTP requests are made. 15 | * Tested: Koala should have complete test coverage, so you can rely on it. Our test coverage is complete and can be run against either mocked responses or the live Facebook servers; we're also on [Travis CI](http://travis-ci.org/arsduo/koala/). 16 | 17 | Installation 18 | --- 19 | 20 | In Bundler: 21 | ```ruby 22 | gem "koala", "~> 1.7.0rc1" 23 | ``` 24 | 25 | Otherwise: 26 | ```bash 27 | [sudo|rvm] gem install koala --pre 28 | ``` 29 | 30 | Graph API 31 | ---- 32 | The Graph API is the simple, slick new interface to Facebook's data. 33 | Using it with Koala is quite straightforward. First, you'll need an access token, which you can get through 34 | Facebook's [Graph API Explorer](https://developers.facebook.com/tools/explorer) (click on 'Get Access Token'). 35 | Then, go exploring: 36 | 37 | ```ruby 38 | @graph = Koala::Facebook::API.new(oauth_access_token) 39 | 40 | profile = @graph.get_object("me") 41 | friends = @graph.get_connections("me", "friends") 42 | @graph.put_connections("me", "feed", :message => "I am writing on my wall!") 43 | 44 | # three-part queries are easy too! 45 | @graph.get_connections("me", "mutualfriends/#{friend_id}") 46 | 47 | # you can even use the new Timeline API 48 | # see https://developers.facebook.com/docs/beta/opengraph/tutorial/ 49 | @graph.put_connections("me", "namespace:action", :object => object_url) 50 | ``` 51 | 52 | The response of most requests is the JSON data returned from the Facebook servers as a Hash. 53 | 54 | When retrieving data that returns an array of results (for example, when calling `API#get_connections` or `API#search`) 55 | a GraphCollection object will be returned, which makes it easy to page through the results: 56 | 57 | ```ruby 58 | # Returns the feed items for the currently logged-in user as a GraphCollection 59 | feed = @graph.get_connections("me", "feed") 60 | feed.each {|f| do_something_with_item(f) } # it's a subclass of Array 61 | next_feed = feed.next_page 62 | 63 | # You can also get an array describing the URL for the next page: [path, arguments] 64 | # This is useful for storing page state across multiple browser requests 65 | next_page_params = feed.next_page_params 66 | page = @graph.get_page(next_page_params) 67 | ``` 68 | 69 | You can also make multiple calls at once using Facebook's batch API: 70 | ```ruby 71 | # Returns an array of results as if they were called non-batch 72 | @graph.batch do |batch_api| 73 | batch_api.get_object('me') 74 | batch_api.put_wall_post('Making a post in a batch.') 75 | end 76 | ``` 77 | 78 | You can pass a "post-processing" block to each of Koala's Graph API methods. This is handy for two reasons: 79 | 80 | 1. You can modify the result returned by the Graph API method: 81 | 82 | education = @graph.get_object("me") { |data| data['education'] } 83 | # returned value only contains the "education" portion of the profile 84 | 85 | 2. You can consume the data in place which is particularly useful in the batch case, so you don't have to pull 86 | the results apart from a long list of array entries: 87 | 88 | @graph.batch do |batch_api| 89 | # Assuming you have database fields "about_me" and "photos" 90 | batch_api.get_object('me') {|me| self.about_me = me } 91 | batch_api.get_connections('me', 'photos') {|photos| self.photos = photos } 92 | end 93 | 94 | Check out the wiki for more details and examples. 95 | 96 | The REST API 97 | ----- 98 | Where the Graph API and the old REST API overlap, you should choose the Graph API. Unfortunately, that overlap is far from complete, and there are many important API calls that can't yet be done via the Graph. 99 | 100 | Fortunately, Koala supports the REST API using the very same interface; to use this, instantiate an API: 101 | ```ruby 102 | @rest = Koala::Facebook::API.new(oauth_access_token) 103 | 104 | @rest.fql_query(my_fql_query) # convenience method 105 | @rest.fql_multiquery(fql_query_hash) # convenience method 106 | @rest.rest_call("stream.publish", arguments_hash) # generic version 107 | ``` 108 | 109 | Of course, you can use the Graph API methods on the same object -- the power of two APIs right in the palm of your hand. 110 | ```ruby 111 | @api = Koala::Facebook::API.new(oauth_access_token) 112 | 113 | @api = Koala::Facebook::API.new(oauth_access_token) 114 | fql = @api.fql_query(my_fql_query) 115 | @api.put_wall_post(process_result(fql)) 116 | ``` 117 | 118 | Configuration 119 | ---- 120 | You can change the host that koala makes requests to (point to a mock server, apigee, runscope etc..) 121 | ```ruby 122 | # config/initializers/koala.rb 123 | require 'koala' 124 | 125 | Koala.configure do |config| 126 | config.graph_server = 'my-graph-mock.mysite.com' 127 | # other common options are `rest_server` and `dialog_host` 128 | # see lib/koala/http_service.rb 129 | end 130 | ``` 131 | 132 | Of course the defaults are the facebook endpoints and you can additionally configure the beta 133 | tier and video upload matching and replacement strings. 134 | 135 | OAuth 136 | ----- 137 | You can use the Graph and REST APIs without an OAuth access token, but the real magic happens when you provide Facebook an OAuth token to prove you're authenticated. Koala provides an OAuth class to make that process easy: 138 | ```ruby 139 | @oauth = Koala::Facebook::OAuth.new(app_id, app_secret, callback_url) 140 | ``` 141 | 142 | If your application uses Koala and the Facebook [JavaScript SDK](http://github.com/facebook/facebook-js-sdk) (formerly Facebook Connect), you can use the OAuth class to parse the cookies: 143 | ```ruby 144 | # parses and returns a hash including the token and the user id 145 | # NOTE: this method can only be called once per session, as the OAuth code 146 | # Facebook supplies can only be redeemed once. Your application must handle 147 | # cross-request storage of this information; you can no longer call this method 148 | # multiple times. 149 | @oauth.get_user_info_from_cookies(cookies) 150 | ``` 151 | And if you have to use the more complicated [redirect-based OAuth process](http://developers.facebook.com/docs/authentication/), Koala helps out there, too: 152 | 153 | ```ruby 154 | # generate authenticating URL 155 | @oauth.url_for_oauth_code 156 | # fetch the access token once you have the code 157 | @oauth.get_access_token(code) 158 | ``` 159 | 160 | You can also get your application's own access token, which can be used without a user session for subscriptions and certain other requests: 161 | ```ruby 162 | @oauth.get_app_access_token 163 | ``` 164 | For those building apps on Facebook, parsing signed requests is simple: 165 | ```ruby 166 | @oauth.parse_signed_request(signed_request_string) 167 | ``` 168 | Or, if for some horrible reason, you're still using session keys, despair not! It's easy to turn them into shiny, modern OAuth tokens: 169 | ```ruby 170 | @oauth.get_token_from_session_key(session_key) 171 | @oauth.get_tokens_from_session_keys(array_of_session_keys) 172 | ``` 173 | That's it! It's pretty simple once you get the hang of it. If you're new to OAuth, though, check out the wiki and the OAuth Playground example site (see below). 174 | 175 | Real-time Updates 176 | ----- 177 | Sometimes, reaching out to Facebook is a pain -- let it reach out to you instead. The Graph API allows your application to subscribe to real-time updates for certain objects in the graph; check the [official Facebook documentation](http://developers.facebook.com/docs/api/realtime) for more details on what objects you can subscribe to and what limitations may apply. 178 | 179 | Koala makes it easy to interact with your applications using the RealtimeUpdates class: 180 | ```ruby 181 | @updates = Koala::Facebook::RealtimeUpdates.new(:app_id => app_id, :secret => secret) 182 | ``` 183 | You can do just about anything with your real-time update subscriptions using the RealtimeUpdates class: 184 | ```ruby 185 | # Add/modify a subscription to updates for when the first_name or last_name fields of any of your users is changed 186 | @updates.subscribe("user", "first_name, last_name", callback_url, verify_token) 187 | 188 | # Get an array of your current subscriptions (one hash for each object you've subscribed to) 189 | @updates.list_subscriptions 190 | 191 | # Unsubscribe from updates for an object 192 | @updates.unsubscribe("user") 193 | ``` 194 | And to top it all off, RealtimeUpdates provides a static method to respond to Facebook servers' verification of your callback URLs: 195 | ```ruby 196 | # Returns the hub.challenge parameter in params if the verify token in params matches verify_token 197 | Koala::Facebook::RealtimeUpdates.meet_challenge(params, your_verify_token) 198 | ``` 199 | For more information about meet_challenge and the RealtimeUpdates class, check out the Real-Time Updates page on the wiki. 200 | 201 | Test Users 202 | ----- 203 | 204 | We also support the test users API, allowing you to conjure up fake users and command them to do your bidding using the Graph or REST API: 205 | ```ruby 206 | @test_users = Koala::Facebook::TestUsers.new(:app_id => id, :secret => secret) 207 | user = @test_users.create(is_app_installed, desired_permissions) 208 | user_graph_api = Koala::Facebook::API.new(user["access_token"]) 209 | # or, if you want to make a whole community: 210 | @test_users.create_network(network_size, is_app_installed, common_permissions) 211 | ``` 212 | Talking to Facebook 213 | ----- 214 | 215 | Koala uses Faraday to make HTTP requests, which means you have complete control over how your app makes HTTP requests to Facebook. You can set Faraday options globally or pass them in on a per-request (or both): 216 | ```ruby 217 | # Set an SSL certificate to avoid Net::HTTP errors 218 | Koala.http_service.http_options = { 219 | :ssl => { :ca_path => "/etc/ssl/certs" } 220 | } 221 | # or on a per-request basis 222 | @api.get_object(id, args_hash, { :timeout => 10 }) 223 | ``` 224 | The HTTP Services wiki page has more information on what options are available, as well as on how to configure your own Faraday middleware stack (for instance, to implement request logging). 225 | 226 | See examples, ask questions 227 | ----- 228 | Some resources to help you as you play with Koala and the Graph API: 229 | 230 | * Complete Koala documentation on the wiki 231 | * The Koala users group on Google Groups, the place for your Koala and API questions 232 | * Facebook's Graph API Explorer, where you can play with the Graph API in your browser 233 | * The Koala-powered OAuth Playground, where you can easily generate OAuth access tokens and any other data needed to test out the APIs or OAuth 234 | * Follow Koala on Facebook and Twitter for SDK updates and occasional news about Facebook API changes. 235 | 236 | 237 | Testing 238 | ----- 239 | 240 | Unit tests are provided for all of Koala's methods. By default, these tests run against mock responses and hence are ready out of the box: 241 | ```bash 242 | # From anywhere in the project directory: 243 | bundle exec rake spec 244 | ``` 245 | 246 | You can also run live tests against Facebook's servers: 247 | ```bash 248 | # Again from anywhere in the project directory: 249 | LIVE=true bundle exec rake spec 250 | # you can also test against Facebook's beta tier 251 | LIVE=true BETA=true bundle exec rake spec 252 | ``` 253 | By default, the live tests are run against test users, so you can run them as frequently as you want. If you want to run them against a real user, however, you can fill in the OAuth token, code, and access\_token values in spec/fixtures/facebook_data.yml. See the wiki for more details. 254 | -------------------------------------------------------------------------------- /spec/cases/realtime_updates_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'base64' 3 | 4 | describe "Koala::Facebook::RealtimeUpdates" do 5 | before :all do 6 | # get oauth data 7 | @app_id = KoalaTest.app_id 8 | @secret = KoalaTest.secret 9 | @callback_url = KoalaTest.oauth_test_data["callback_url"] 10 | @app_access_token = KoalaTest.app_access_token 11 | 12 | # check OAuth data 13 | unless @app_id && @secret && @callback_url && @app_access_token 14 | raise Exception, "Must supply OAuth app id, secret, app_access_token, and callback to run live subscription tests!" 15 | end 16 | 17 | # get subscription data 18 | @verify_token = KoalaTest.subscription_test_data["verify_token"] 19 | @challenge_data = KoalaTest.subscription_test_data["challenge_data"] 20 | @subscription_path = KoalaTest.subscription_test_data["subscription_path"] 21 | 22 | # check subscription data 23 | unless @verify_token && @challenge_data && @subscription_path 24 | raise Exception, "Must supply verify_token and equivalent challenge_data to run subscription tests!" 25 | end 26 | end 27 | 28 | before :each do 29 | @updates = Koala::Facebook::RealtimeUpdates.new(:app_id => @app_id, :secret => @secret) 30 | end 31 | 32 | describe ".new" do 33 | # basic initialization 34 | it "initializes properly with an app_id and an app_access_token" do 35 | updates = Koala::Facebook::RealtimeUpdates.new(:app_id => @app_id, :app_access_token => @app_access_token) 36 | updates.should be_a(Koala::Facebook::RealtimeUpdates) 37 | end 38 | 39 | # attributes 40 | it "allows read access to app_id" do 41 | # in Ruby 1.9, .method returns symbols 42 | Koala::Facebook::RealtimeUpdates.instance_methods.map(&:to_sym).should include(:app_id) 43 | Koala::Facebook::RealtimeUpdates.instance_methods.map(&:to_sym).should_not include(:app_id=) 44 | end 45 | 46 | it "allows read access to app_access_token" do 47 | # in Ruby 1.9, .method returns symbols 48 | Koala::Facebook::RealtimeUpdates.instance_methods.map(&:to_sym).should include(:app_access_token) 49 | Koala::Facebook::RealtimeUpdates.instance_methods.map(&:to_sym).should_not include(:app_access_token=) 50 | end 51 | 52 | it "allows read access to secret" do 53 | # in Ruby 1.9, .method returns symbols 54 | Koala::Facebook::RealtimeUpdates.instance_methods.map(&:to_sym).should include(:secret) 55 | Koala::Facebook::RealtimeUpdates.instance_methods.map(&:to_sym).should_not include(:secret=) 56 | end 57 | 58 | it "allows read access to api" do 59 | # in Ruby 1.9, .method returns symbols 60 | Koala::Facebook::RealtimeUpdates.instance_methods.map(&:to_sym).should include(:api) 61 | Koala::Facebook::RealtimeUpdates.instance_methods.map(&:to_sym).should_not include(:api=) 62 | end 63 | 64 | # old graph_api accessor 65 | it "returns the api object when graph_api is called" do 66 | updates = Koala::Facebook::RealtimeUpdates.new(:app_id => @app_id, :secret => @secret) 67 | updates.graph_api.should == updates.api 68 | end 69 | 70 | it "fire a deprecation warning when graph_api is called" do 71 | updates = Koala::Facebook::RealtimeUpdates.new(:app_id => @app_id, :secret => @secret) 72 | Koala::Utils.should_receive(:deprecate) 73 | updates.graph_api 74 | end 75 | 76 | # init with secret / fetching the token 77 | it "initializes properly with an app_id and a secret" do 78 | updates = Koala::Facebook::RealtimeUpdates.new(:app_id => @app_id, :secret => @secret) 79 | updates.should be_a(Koala::Facebook::RealtimeUpdates) 80 | end 81 | 82 | it "fetches an app_token from Facebook when provided an app_id and a secret" do 83 | updates = Koala::Facebook::RealtimeUpdates.new(:app_id => @app_id, :secret => @secret) 84 | updates.app_access_token.should_not be_nil 85 | end 86 | 87 | it "uses the OAuth class to fetch a token when provided an app_id and a secret" do 88 | oauth = Koala::Facebook::OAuth.new(@app_id, @secret) 89 | token = oauth.get_app_access_token 90 | oauth.should_receive(:get_app_access_token).and_return(token) 91 | Koala::Facebook::OAuth.should_receive(:new).with(@app_id, @secret).and_return(oauth) 92 | updates = Koala::Facebook::RealtimeUpdates.new(:app_id => @app_id, :secret => @secret) 93 | end 94 | 95 | it "sets up the with the app acces token" do 96 | updates = Koala::Facebook::RealtimeUpdates.new(:app_id => @app_id, :app_access_token => @app_access_token) 97 | updates.api.should be_a(Koala::Facebook::API) 98 | updates.api.access_token.should == @app_access_token 99 | end 100 | 101 | end 102 | 103 | describe "#subscribe" do 104 | it "makes a POST to the subscription path" do 105 | @updates.api.should_receive(:graph_call).with(@updates.subscription_path, anything, "post", anything) 106 | @updates.subscribe("user", "name", @subscription_path, @verify_token) 107 | end 108 | 109 | it "properly formats the subscription request" do 110 | obj = "user" 111 | fields = "name" 112 | @updates.api.should_receive(:graph_call).with(anything, hash_including( 113 | :object => obj, 114 | :fields => fields, 115 | :callback_url => @subscription_path, 116 | :verify_token => @verify_token 117 | ), anything, anything) 118 | @updates.subscribe("user", "name", @subscription_path, @verify_token) 119 | end 120 | 121 | pending "doesn't require a verify_token" do 122 | # see https://github.com/arsduo/koala/issues/150 123 | obj = "user" 124 | fields = "name" 125 | @updates.api.should_not_receive(:graph_call).with(anything, hash_including(:verify_token => anything), anything, anything) 126 | @updates.subscribe("user", "name", @subscription_path) 127 | end 128 | 129 | it "requires verify_token" do 130 | expect { @updates.subscribe("user", "name", @subscription_path) }.to raise_exception 131 | end 132 | 133 | it "accepts an options hash" do 134 | options = {:a => 2, :b => "c"} 135 | @updates.api.should_receive(:graph_call).with(anything, anything, anything, hash_including(options)) 136 | @updates.subscribe("user", "name", @subscription_path, @verify_token, options) 137 | end 138 | 139 | describe "in practice" do 140 | it "sends a subscription request" do 141 | expect { @updates.subscribe("user", "name", @subscription_path, @verify_token) }.to_not raise_error 142 | end 143 | 144 | pending "sends a subscription request without a verify token" do 145 | expect { @updates.subscribe("user", "name", @subscription_path) }.to_not raise_error 146 | end 147 | 148 | it "fails if you try to hit an invalid path on your valid server" do 149 | expect { result = @updates.subscribe("user", "name", @subscription_path + "foo", @verify_token) }.to raise_exception(Koala::Facebook::APIError) 150 | end 151 | 152 | it "fails to send a subscription request to an invalid server" do 153 | expect { @updates.subscribe("user", "name", "foo", @verify_token) }.to raise_exception(Koala::Facebook::APIError) 154 | end 155 | end 156 | end 157 | 158 | describe "#unsubscribe" do 159 | it "makes a DELETE to the subscription path" do 160 | @updates.api.should_receive(:graph_call).with(@updates.subscription_path, anything, "delete", anything) 161 | @updates.unsubscribe("user") 162 | end 163 | 164 | it "includes the object if provided" do 165 | obj = "user" 166 | @updates.api.should_receive(:graph_call).with(anything, hash_including(:object => obj), anything, anything) 167 | @updates.unsubscribe(obj) 168 | end 169 | 170 | it "accepts an options hash" do 171 | options = {:a => 2, :b => "C"} 172 | @updates.api.should_receive(:graph_call).with(anything, anything, anything, hash_including(options)) 173 | @updates.unsubscribe("user", options) 174 | end 175 | 176 | describe "in practice" do 177 | it "unsubscribes a valid individual object successfully" do 178 | expect { @updates.unsubscribe("user") }.to_not raise_error 179 | end 180 | 181 | it "unsubscribes all subscriptions successfully" do 182 | expect { @updates.unsubscribe }.to_not raise_error 183 | end 184 | 185 | it "fails when an invalid object is provided to unsubscribe" do 186 | expect { @updates.unsubscribe("kittens") }.to raise_error(Koala::Facebook::APIError) 187 | end 188 | end 189 | end 190 | 191 | describe "#list_subscriptions" do 192 | it "GETs the subscription path" do 193 | @updates.api.should_receive(:graph_call).with(@updates.subscription_path, anything, "get", anything) 194 | @updates.list_subscriptions 195 | end 196 | 197 | it "accepts options" do 198 | options = {:a => 3, :b => "D"} 199 | @updates.api.should_receive(:graph_call).with(anything, anything, anything, hash_including(options)) 200 | @updates.list_subscriptions(options) 201 | end 202 | 203 | describe "in practice" do 204 | it "lists subscriptions properly" do 205 | @updates.list_subscriptions.should be_a(Array) 206 | end 207 | end 208 | end 209 | 210 | describe "#subscription_path" do 211 | it "returns the app_id/subscriptions" do 212 | @updates.subscription_path.should == "#{@app_id}/subscriptions" 213 | end 214 | end 215 | 216 | describe ".validate_update" do 217 | it "returns false if there is no X-Hub-Signature header" do 218 | @updates.validate_update("", {}).should be_false 219 | end 220 | 221 | it "returns false if the signature doesn't match the body" do 222 | @updates.validate_update("", {"X-Hub-Signature" => "sha1=badsha1"}).should be_false 223 | end 224 | 225 | it "results true if the signature matches the body with the secret" do 226 | body = "BODY" 227 | signature = OpenSSL::HMAC.hexdigest('sha1', @secret, body).chomp 228 | @updates.validate_update(body, {"X-Hub-Signature" => "sha1=#{signature}"}).should be_true 229 | end 230 | 231 | it "results true with alternate HTTP_X_HUB_SIGNATURE header" do 232 | body = "BODY" 233 | signature = OpenSSL::HMAC.hexdigest('sha1', @secret, body).chomp 234 | @updates.validate_update(body, {"HTTP_X_HUB_SIGNATURE" => "sha1=#{signature}"}).should be_true 235 | end 236 | 237 | end 238 | 239 | describe ".meet_challenge" do 240 | it "returns false if hub.mode isn't subscribe" do 241 | params = {'hub.mode' => 'not subscribe'} 242 | Koala::Facebook::RealtimeUpdates.meet_challenge(params).should be_false 243 | end 244 | 245 | it "doesn't evaluate the block if hub.mode isn't subscribe" do 246 | params = {'hub.mode' => 'not subscribe'} 247 | block_evaluated = false 248 | Koala::Facebook::RealtimeUpdates.meet_challenge(params){|token| block_evaluated = true} 249 | block_evaluated.should be_false 250 | end 251 | 252 | it "returns false if not given a verify_token or block" do 253 | params = {'hub.mode' => 'subscribe'} 254 | Koala::Facebook::RealtimeUpdates.meet_challenge(params).should be_false 255 | end 256 | 257 | describe "and mode is 'subscribe'" do 258 | before(:each) do 259 | @params = {'hub.mode' => 'subscribe'} 260 | end 261 | 262 | describe "and a token is given" do 263 | before(:each) do 264 | @token = 'token' 265 | @params['hub.verify_token'] = @token 266 | end 267 | 268 | it "returns false if the given verify token doesn't match" do 269 | Koala::Facebook::RealtimeUpdates.meet_challenge(@params, @token + '1').should be_false 270 | end 271 | 272 | it "returns the challenge if the given verify token matches" do 273 | @params['hub.challenge'] = 'challenge val' 274 | Koala::Facebook::RealtimeUpdates.meet_challenge(@params, @token).should == @params['hub.challenge'] 275 | end 276 | end 277 | 278 | describe "and a block is given" do 279 | before :each do 280 | @params['hub.verify_token'] = @token 281 | end 282 | 283 | it "gives the block the token as a parameter" do 284 | Koala::Facebook::RealtimeUpdates.meet_challenge(@params) do |token| 285 | token.should == @token 286 | end 287 | end 288 | 289 | it "returns false if the given block return false" do 290 | Koala::Facebook::RealtimeUpdates.meet_challenge(@params) do |token| 291 | false 292 | end.should be_false 293 | end 294 | 295 | it "returns false if the given block returns nil" do 296 | Koala::Facebook::RealtimeUpdates.meet_challenge(@params) do |token| 297 | nil 298 | end.should be_false 299 | end 300 | 301 | it "returns the challenge if the given block returns true" do 302 | @params['hub.challenge'] = 'challenge val' 303 | Koala::Facebook::RealtimeUpdates.meet_challenge(@params) do |token| 304 | true 305 | end.should be_true 306 | end 307 | end 308 | end 309 | end 310 | end 311 | -------------------------------------------------------------------------------- /spec/cases/test_users_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Koala::Facebook::TestUsers" do 4 | before :all do 5 | # get oauth data 6 | @app_id = KoalaTest.app_id 7 | @secret = KoalaTest.secret 8 | @app_access_token = KoalaTest.app_access_token 9 | 10 | @test_users = Koala::Facebook::TestUsers.new({:app_access_token => @app_access_token, :app_id => @app_id}) 11 | 12 | # check OAuth data 13 | unless @app_id && @secret && @app_access_token 14 | raise Exception, "Must supply OAuth app id, secret, app_access_token, and callback to run live subscription tests!" 15 | end 16 | end 17 | 18 | after :each do 19 | # clean up any test users 20 | # Facebook only allows us 500 test users per app, so we have to clean up 21 | # This would be a good place to clean up and accumulate all of them for 22 | # later deletion. 23 | unless KoalaTest.mock_interface? || @stubbed 24 | ((@network || []) + [@user1, @user2]).each do |u| 25 | puts "Unable to delete test user #{u.inspect}" if u && !(@test_users.delete(u) rescue false) 26 | end 27 | end 28 | end 29 | 30 | describe "when initializing" do 31 | # basic initialization 32 | it "initializes properly with an app_id and an app_access_token" do 33 | test_users = Koala::Facebook::TestUsers.new(:app_id => @app_id, :app_access_token => @app_access_token) 34 | test_users.should be_a(Koala::Facebook::TestUsers) 35 | end 36 | 37 | # init with secret / fetching the token 38 | it "initializes properly with an app_id and a secret" do 39 | test_users = Koala::Facebook::TestUsers.new(:app_id => @app_id, :secret => @secret) 40 | test_users.should be_a(Koala::Facebook::TestUsers) 41 | end 42 | 43 | it "uses the OAuth class to fetch a token when provided an app_id and a secret" do 44 | oauth = Koala::Facebook::OAuth.new(@app_id, @secret) 45 | token = oauth.get_app_access_token 46 | oauth.should_receive(:get_app_access_token).and_return(token) 47 | Koala::Facebook::OAuth.should_receive(:new).with(@app_id, @secret).and_return(oauth) 48 | test_users = Koala::Facebook::TestUsers.new(:app_id => @app_id, :secret => @secret) 49 | end 50 | 51 | # attributes 52 | it "allows read access to app_id, app_access_token, and secret" do 53 | # in Ruby 1.9, .method returns symbols 54 | Koala::Facebook::TestUsers.instance_methods.map(&:to_sym).should include(:app_id) 55 | Koala::Facebook::TestUsers.instance_methods.map(&:to_sym).should_not include(:app_id=) 56 | end 57 | 58 | it "allows read access to app_access_token" do 59 | # in Ruby 1.9, .method returns symbols 60 | Koala::Facebook::TestUsers.instance_methods.map(&:to_sym).should include(:app_access_token) 61 | Koala::Facebook::TestUsers.instance_methods.map(&:to_sym).should_not include(:app_access_token=) 62 | end 63 | 64 | it "allows read access to secret" do 65 | # in Ruby 1.9, .method returns symbols 66 | Koala::Facebook::TestUsers.instance_methods.map(&:to_sym).should include(:secret) 67 | Koala::Facebook::TestUsers.instance_methods.map(&:to_sym).should_not include(:secret=) 68 | end 69 | 70 | it "allows read access to api" do 71 | # in Ruby 1.9, .method returns symbols 72 | Koala::Facebook::TestUsers.instance_methods.map(&:to_sym).should include(:api) 73 | Koala::Facebook::TestUsers.instance_methods.map(&:to_sym).should_not include(:api=) 74 | end 75 | 76 | # old graph_api accessor 77 | it "returns the api object when graph_api is called" do 78 | test_users = Koala::Facebook::TestUsers.new(:app_id => @app_id, :secret => @secret) 79 | test_users.graph_api.should == test_users.api 80 | end 81 | 82 | it "fire a deprecation warning when graph_api is called" do 83 | test_users = Koala::Facebook::TestUsers.new(:app_id => @app_id, :secret => @secret) 84 | Koala::Utils.should_receive(:deprecate) 85 | test_users.graph_api 86 | end 87 | end 88 | 89 | describe "when used without network" do 90 | # TEST USER MANAGEMENT 91 | 92 | describe "#create" do 93 | it "creates a test user when not given installed" do 94 | result = @test_users.create(false) 95 | @user1 = result["id"] 96 | result.should be_a(Hash) 97 | (result["id"] && result["access_token"] && result["login_url"]).should 98 | end 99 | 100 | it "creates a test user when not given installed, ignoring permissions" do 101 | result = @test_users.create(false, "read_stream") 102 | @user1 = result["id"] 103 | result.should be_a(Hash) 104 | (result["id"] && result["access_token"] && result["login_url"]).should 105 | end 106 | 107 | it "accepts permissions as a string" do 108 | @test_users.graph_api.should_receive(:graph_call).with(anything, hash_including("permissions" => "read_stream,publish_stream"), anything, anything) 109 | result = @test_users.create(true, "read_stream,publish_stream") 110 | end 111 | 112 | it "accepts permissions as an array" do 113 | @test_users.graph_api.should_receive(:graph_call).with(anything, hash_including("permissions" => "read_stream,publish_stream"), anything, anything) 114 | result = @test_users.create(true, ["read_stream", "publish_stream"]) 115 | end 116 | 117 | it "creates a test user when given installed and a permission" do 118 | result = @test_users.create(true, "read_stream") 119 | @user1 = result["id"] 120 | result.should be_a(Hash) 121 | (result["id"] && result["access_token"] && result["login_url"]).should 122 | end 123 | 124 | it "lets you specify other graph arguments, like uid and access token" do 125 | args = {:uid => "some test user ID", :owner_access_token => "some owner access token"} 126 | @test_users.graph_api.should_receive(:graph_call).with(anything, hash_including(args), anything, anything) 127 | @test_users.create(true, nil, args) 128 | end 129 | 130 | it "lets you specify http options that get passed through to the graph call" do 131 | options = {:some_http_option => true} 132 | @test_users.graph_api.should_receive(:graph_call).with(anything, anything, anything, options) 133 | @test_users.create(true, nil, {}, options) 134 | end 135 | end 136 | 137 | describe "#list" do 138 | before :each do 139 | @user1 = @test_users.create(true, "read_stream") 140 | @user2 = @test_users.create(true, "read_stream,user_interests") 141 | end 142 | 143 | it "lists test users" do 144 | result = @test_users.list 145 | result.should be_an(Array) 146 | first_user, second_user = result[0], result[1] 147 | (first_user["id"] && first_user["access_token"] && first_user["login_url"]).should 148 | (second_user["id"] && second_user["access_token"] && second_user["login_url"]).should 149 | end 150 | 151 | it "accepts http options" do 152 | @stubbed = true 153 | options = {:some_http_option => true} 154 | @test_users.api.should_receive(:graph_call).with(anything, anything, anything, options) 155 | @test_users.list(options) 156 | end 157 | end 158 | 159 | describe "#delete" do 160 | before :each do 161 | @user1 = @test_users.create(true, "read_stream") 162 | @user2 = @test_users.create(true, "read_stream,user_interests") 163 | end 164 | 165 | it "deletes a user by id" do 166 | @test_users.delete(@user1['id']).should be_true 167 | @user1 = nil 168 | end 169 | 170 | it "deletes a user by hash" do 171 | @test_users.delete(@user2).should be_true 172 | @user2 = nil 173 | end 174 | 175 | it "does not delete users when provided a false ID" do 176 | lambda { @test_users.delete("#{@user1['id']}1") }.should raise_exception(Koala::Facebook::APIError) 177 | end 178 | 179 | it "lets you specify http options that get passed through to the graph call" do 180 | options = {:some_http_option => true} 181 | # technically this goes through delete_object, but this makes it less brittle 182 | @stubbed = true 183 | @test_users.graph_api.should_receive(:graph_call).with(anything, anything, anything, options) 184 | @test_users.delete("user", options) 185 | end 186 | end 187 | 188 | describe "#delete_all" do 189 | it "deletes the batch API to deleten all users found by the list commnand" do 190 | array = 200.times.collect { {"id" => rand}} 191 | @test_users.should_receive(:list).and_return(array, []) 192 | batch_api = stub("batch API") 193 | @test_users.api.should_receive(:batch).and_yield(batch_api).any_number_of_times 194 | array.each {|item| batch_api.should_receive(:delete_object).with(item["id"]) } 195 | @test_users.delete_all 196 | end 197 | 198 | it "accepts http options that get passed to both list and the batch call" do 199 | options = {:some_http_option => true} 200 | @test_users.should_receive(:list).with(options).and_return([{"id" => rand}], []) 201 | @test_users.api.should_receive(:batch).with(options) 202 | @test_users.delete_all(options) 203 | end 204 | 205 | it "breaks if Facebook sends back the same list twice" do 206 | list = [{"id" => rand}] 207 | @test_users.should_receive(:list).any_number_of_times.and_return(list) 208 | @test_users.api.should_receive(:batch).once 209 | @test_users.delete_all 210 | end 211 | end 212 | 213 | describe "#update" do 214 | before :each do 215 | @updates = {:name => "Foo Baz"} 216 | # we stub out :graph_call, but still need to be able to delete the users 217 | @test_users2 = Koala::Facebook::TestUsers.new(:app_id => @test_users.app_id, :app_access_token => @test_users.app_access_token) 218 | end 219 | 220 | it "makes a POST with the test user Graph API " do 221 | @user1 = @test_users2.create(true) 222 | @test_users2.graph_api.should_receive(:graph_call).with(anything, anything, "post", anything) 223 | @test_users2.update(@user1, @updates) 224 | end 225 | 226 | it "makes a request to the test user with the update params " do 227 | @user1 = @test_users2.create(true) 228 | @test_users2.graph_api.should_receive(:graph_call).with(@user1["id"], @updates, anything, anything) 229 | @test_users2.update(@user1, @updates) 230 | end 231 | 232 | it "accepts an options hash" do 233 | options = {:some_http_option => true} 234 | @stubbed = true 235 | @test_users2.graph_api.should_receive(:graph_call).with(anything, anything, anything, options) 236 | @test_users2.update("foo", @updates, options) 237 | end 238 | 239 | it "works" do 240 | @user1 = @test_users.create(true) 241 | @test_users.update(@user1, @updates) 242 | user_info = Koala::Facebook::API.new(@user1["access_token"]).get_object(@user1["id"]) 243 | user_info["name"].should == @updates[:name] 244 | end 245 | end 246 | 247 | describe "#befriend" do 248 | before :each do 249 | @user1 = @test_users.create(true, "read_stream") 250 | @user2 = @test_users.create(true, "read_stream,user_interests") 251 | end 252 | 253 | it "makes two users into friends with string hashes" do 254 | result = @test_users.befriend(@user1, @user2) 255 | result.should be_true 256 | end 257 | 258 | it "makes two users into friends with symbol hashes" do 259 | new_user_1 = {} 260 | @user1.each_pair {|k, v| new_user_1[k.to_sym] = v} 261 | new_user_2 = {} 262 | @user2.each_pair {|k, v| new_user_2[k.to_sym] = v} 263 | 264 | result = @test_users.befriend(new_user_1, new_user_2) 265 | result.should be_true 266 | end 267 | 268 | it "does not accept user IDs anymore" do 269 | lambda { @test_users.befriend(@user1["id"], @user2["id"]) }.should raise_exception 270 | end 271 | 272 | it "accepts http options passed to both calls" do 273 | options = {:some_http_option => true} 274 | # should come twice, once for each user 275 | @stubbed = true 276 | Koala.http_service.should_receive(:make_request).with(anything, anything, anything, options).twice.and_return(Koala::HTTPService::Response.new(200, "{}", {})) 277 | @test_users.befriend(@user1, @user2, options) 278 | end 279 | end 280 | end # when used without network 281 | 282 | describe "#test_user_accounts_path" do 283 | it "returns the app_id/accounts/test-users" do 284 | @test_users.test_user_accounts_path.should == "/#{@app_id}/accounts/test-users" 285 | end 286 | end 287 | 288 | describe "when creating a network of friends" do 289 | before :each do 290 | @network = [] 291 | 292 | if KoalaTest.mock_interface? 293 | id_counter = 999999900 294 | @test_users.stub!(:create).and_return do 295 | id_counter += 1 296 | {"id" => id_counter, "access_token" => @token, "login_url" => "https://www.facebook.com/platform/test_account.."} 297 | end 298 | @test_users.stub!(:befriend).and_return(true) 299 | @test_users.stub!(:delete).and_return(true) 300 | end 301 | end 302 | 303 | describe "tests that create users" do 304 | it "creates a 5 person network" do 305 | size = 5 306 | @network = @test_users.create_network(size) 307 | @network.should be_a(Array) 308 | @network.size.should == size 309 | end 310 | end 311 | 312 | it "has no built-in network size limit" do 313 | times = 100 314 | @test_users.should_receive(:create).exactly(times).times 315 | @test_users.stub!(:befriend) 316 | @test_users.create_network(times) 317 | end 318 | 319 | it "passes on the installed and permissions parameters to create" do 320 | perms = ["read_stream", "offline_access"] 321 | installed = false 322 | count = 25 323 | @test_users.should_receive(:create).exactly(count).times.with(installed, perms, anything) 324 | @test_users.stub!(:befriend) 325 | @test_users.create_network(count, installed, perms) 326 | end 327 | 328 | it "accepts http options that are passed to both the create and befriend calls" do 329 | count = 25 330 | options = {:some_http_option => true} 331 | @test_users.should_receive(:create).exactly(count).times.with(anything, anything, options).and_return({}) 332 | # there are more befriends than creates, but we don't need to do the extra work to calculate out the exact # 333 | @test_users.should_receive(:befriend).at_least(count).times.with(anything, anything, options) 334 | @test_users.create_network(count, true, "", options) 335 | end 336 | end # when creating network 337 | end # describe Koala TestUsers 338 | -------------------------------------------------------------------------------- /lib/koala/oauth.rb: -------------------------------------------------------------------------------- 1 | # OpenSSL and Base64 are required to support signed_request 2 | require 'openssl' 3 | require 'base64' 4 | 5 | module Koala 6 | module Facebook 7 | class OAuth 8 | attr_reader :app_id, :app_secret, :oauth_callback_url 9 | 10 | # Creates a new OAuth client. 11 | # 12 | # @param app_id [String, Integer] a Facebook application ID 13 | # @param app_secret a Facebook application secret 14 | # @param oauth_callback_url the URL in your app to which users authenticating with OAuth will be sent 15 | def initialize(app_id, app_secret, oauth_callback_url = nil) 16 | @app_id = app_id 17 | @app_secret = app_secret 18 | @oauth_callback_url = oauth_callback_url 19 | end 20 | 21 | # Parses the cookie set Facebook's JavaScript SDK. 22 | # 23 | # @note this method can only be called once per session, as the OAuth code 24 | # Facebook supplies can only be redeemed once. Your application 25 | # must handle cross-request storage of this information; you can no 26 | # longer call this method multiple times. (This works out, as the 27 | # method has to make a call to FB's servers anyway, which you don't 28 | # want on every call.) 29 | # 30 | # @param cookie_hash a set of cookies that includes the Facebook cookie. 31 | # You can pass Rack/Rails/Sinatra's cookie hash directly to this method. 32 | # 33 | # @return the authenticated user's information as a hash, or nil. 34 | def get_user_info_from_cookies(cookie_hash) 35 | if signed_cookie = cookie_hash["fbsr_#{@app_id}"] 36 | parse_signed_cookie(signed_cookie) 37 | elsif unsigned_cookie = cookie_hash["fbs_#{@app_id}"] 38 | parse_unsigned_cookie(unsigned_cookie) 39 | end 40 | end 41 | alias_method :get_user_info_from_cookie, :get_user_info_from_cookies 42 | 43 | # Parses the cookie set Facebook's JavaScript SDK and returns only the user ID. 44 | # 45 | # @note (see #get_user_info_from_cookie) 46 | # 47 | # @param (see #get_user_info_from_cookie) 48 | # 49 | # @return the authenticated user's Facebook ID, or nil. 50 | def get_user_from_cookies(cookies) 51 | Koala::Utils.deprecate("Due to Facebook changes, you can only redeem an OAuth code once; it is therefore recommended not to use this method, as it will consume the code without providing you the access token. See https://developers.facebook.com/roadmap/completed-changes/#december-2012.") 52 | if signed_cookie = cookies["fbsr_#{@app_id}"] 53 | if components = parse_signed_request(signed_cookie) 54 | components["user_id"] 55 | end 56 | elsif info = get_user_info_from_cookies(cookies) 57 | # Parsing unsigned cookie 58 | info["uid"] 59 | end 60 | end 61 | alias_method :get_user_from_cookie, :get_user_from_cookies 62 | 63 | # URLs 64 | 65 | # Builds an OAuth URL, where users will be prompted to log in and for any desired permissions. 66 | # When the users log in, you receive a callback with their 67 | # See http://developers.facebook.com/docs/authentication/. 68 | # 69 | # @see #url_for_access_token 70 | # 71 | # @note The server-side authentication and dialog methods should only be used 72 | # if your application can't use the Facebook Javascript SDK, 73 | # which provides a much better user experience. 74 | # See http://developers.facebook.com/docs/reference/javascript/. 75 | # 76 | # @param options any query values to add to the URL, as well as any special/required values listed below. 77 | # @option options permissions an array or comma-separated string of desired permissions 78 | # @option options state a unique string to serve as a CSRF (cross-site request 79 | # forgery) token -- highly recommended for security. See 80 | # https://developers.facebook.com/docs/howtos/login/server-side-login/ 81 | # 82 | # @raise ArgumentError if no OAuth callback was specified in OAuth#new or in options as :redirect_uri 83 | # 84 | # @return an OAuth URL you can send your users to 85 | def url_for_oauth_code(options = {}) 86 | # for permissions, see http://developers.facebook.com/docs/authentication/permissions 87 | if permissions = options.delete(:permissions) 88 | options[:scope] = permissions.is_a?(Array) ? permissions.join(",") : permissions 89 | end 90 | url_options = {:client_id => @app_id}.merge(options) 91 | 92 | # Creates the URL for oauth authorization for a given callback and optional set of permissions 93 | build_url("https://#{Koala.config.dialog_host}/dialog/oauth", true, url_options) 94 | end 95 | 96 | # Once you receive an OAuth code, you need to redeem it from Facebook using an appropriate URL. 97 | # (This is done by your server behind the scenes.) 98 | # See http://developers.facebook.com/docs/authentication/. 99 | # 100 | # @see #url_for_oauth_code 101 | # 102 | # @note (see #url_for_oauth_code) 103 | # 104 | # @param code an OAuth code received from Facebook 105 | # @param options any additional query parameters to add to the URL 106 | # 107 | # @raise (see #url_for_oauth_code) 108 | # 109 | # @return an URL your server can query for the user's access token 110 | def url_for_access_token(code, options = {}) 111 | # Creates the URL for the token corresponding to a given code generated by Facebook 112 | url_options = { 113 | :client_id => @app_id, 114 | :code => code, 115 | :client_secret => @app_secret 116 | }.merge(options) 117 | build_url("https://#{Koala.config.graph_server}/oauth/access_token", true, url_options) 118 | end 119 | 120 | # Builds a URL for a given dialog (feed, friends, OAuth, pay, send, etc.) 121 | # See http://developers.facebook.com/docs/reference/dialogs/. 122 | # 123 | # @note (see #url_for_oauth_code) 124 | # 125 | # @param dialog_type the kind of Facebook dialog you want to show 126 | # @param options any additional query parameters to add to the URL 127 | # 128 | # @return an URL your server can query for the user's access token 129 | def url_for_dialog(dialog_type, options = {}) 130 | # some endpoints require app_id, some client_id, supply both doesn't seem to hurt 131 | url_options = {:app_id => @app_id, :client_id => @app_id}.merge(options) 132 | build_url("http://#{Koala.config.dialog_host}/dialog/#{dialog_type}", true, url_options) 133 | end 134 | 135 | # access tokens 136 | 137 | # Fetches an access token, token expiration, and other info from Facebook. 138 | # Useful when you've received an OAuth code using the server-side authentication process. 139 | # @see url_for_oauth_code 140 | # 141 | # @note (see #url_for_oauth_code) 142 | # 143 | # @param code (see #url_for_access_token) 144 | # @param options any additional parameters to send to Facebook when redeeming the token 145 | # 146 | # @raise Koala::Facebook::OAuthTokenRequestError if Facebook returns an error response 147 | # 148 | # @return a hash of the access token info returned by Facebook (token, expiration, etc.) 149 | def get_access_token_info(code, options = {}) 150 | # convenience method to get a parsed token from Facebook for a given code 151 | # should this require an OAuth callback URL? 152 | get_token_from_server({:code => code, :redirect_uri => options[:redirect_uri] || @oauth_callback_url}, false, options) 153 | end 154 | 155 | 156 | # Fetches the access token (ignoring expiration and other info) from Facebook. 157 | # Useful when you've received an OAuth code using the server-side authentication process. 158 | # @see get_access_token_info 159 | # 160 | # @note (see #url_for_oauth_code) 161 | # 162 | # @param (see #get_access_token_info) 163 | # 164 | # @raise (see #get_access_token_info) 165 | # 166 | # @return the access token 167 | def get_access_token(code, options = {}) 168 | # upstream methods will throw errors if needed 169 | if info = get_access_token_info(code, options) 170 | string = info["access_token"] 171 | end 172 | end 173 | 174 | # Fetches the application's access token, along with any other information provided by Facebook. 175 | # See http://developers.facebook.com/docs/authentication/ (search for App Login). 176 | # 177 | # @param options any additional parameters to send to Facebook when redeeming the token 178 | # 179 | # @return the application access token and other information (expiration, etc.) 180 | def get_app_access_token_info(options = {}) 181 | # convenience method to get a the application's sessionless access token 182 | get_token_from_server({:grant_type => 'client_credentials'}, true, options) 183 | end 184 | 185 | # Fetches the application's access token (ignoring expiration and other info). 186 | # @see get_app_access_token_info 187 | # 188 | # @param (see #get_app_access_token_info) 189 | # 190 | # @return the application access token 191 | def get_app_access_token(options = {}) 192 | if info = get_app_access_token_info(options) 193 | string = info["access_token"] 194 | end 195 | end 196 | 197 | # Fetches an access_token with extended expiration time, along with any other information provided by Facebook. 198 | # See https://developers.facebook.com/docs/offline-access-deprecation/#extend_token (search for fb_exchange_token). 199 | # 200 | # @param access_token the access token to exchange 201 | # @param options any additional parameters to send to Facebook when exchanging tokens. 202 | # 203 | # @return the access token with extended expiration time and other information (expiration, etc.) 204 | def exchange_access_token_info(access_token, options = {}) 205 | get_token_from_server({ 206 | :grant_type => 'fb_exchange_token', 207 | :fb_exchange_token => access_token 208 | }, true, options) 209 | end 210 | 211 | # Fetches an access token with extended expiration time (ignoring expiration and other info). 212 | 213 | # @see exchange_access_token_info 214 | # 215 | # @param (see #exchange_access_token_info) 216 | # 217 | # @return A new access token or the existing one, set to expire in 60 days. 218 | def exchange_access_token(access_token, options = {}) 219 | if info = exchange_access_token_info(access_token, options) 220 | info["access_token"] 221 | end 222 | end 223 | 224 | # Parses a signed request string provided by Facebook to canvas apps or in a secure cookie. 225 | # 226 | # @param input the signed request from Facebook 227 | # 228 | # @raise OAuthSignatureError if the signature is incomplete, invalid, or using an unsupported algorithm 229 | # 230 | # @return a hash of the validated request information 231 | def parse_signed_request(input) 232 | encoded_sig, encoded_envelope = input.split('.', 2) 233 | raise OAuthSignatureError, 'Invalid (incomplete) signature data' unless encoded_sig && encoded_envelope 234 | 235 | signature = base64_url_decode(encoded_sig).unpack("H*").first 236 | envelope = MultiJson.load(base64_url_decode(encoded_envelope)) 237 | 238 | raise OAuthSignatureError, "Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256' 239 | 240 | # now see if the signature is valid (digest, key, data) 241 | hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @app_secret, encoded_envelope) 242 | raise OAuthSignatureError, 'Invalid signature' if (signature != hmac) 243 | 244 | envelope 245 | end 246 | 247 | # Old session key code 248 | 249 | # @deprecated Facebook no longer provides session keys. 250 | def get_token_info_from_session_keys(sessions, options = {}) 251 | Koala::Utils.deprecate("Facebook no longer provides session keys. The relevant OAuth methods will be removed in the next release.") 252 | 253 | # fetch the OAuth tokens from Facebook 254 | response = fetch_token_string({ 255 | :type => 'client_cred', 256 | :sessions => sessions.join(",") 257 | }, true, "exchange_sessions", options) 258 | 259 | # Facebook returns an empty body in certain error conditions 260 | if response == "" 261 | raise BadFacebookResponse.new(200, '', "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!") 262 | end 263 | 264 | MultiJson.load(response) 265 | end 266 | 267 | # @deprecated (see #get_token_info_from_session_keys) 268 | def get_tokens_from_session_keys(sessions, options = {}) 269 | # get the original hash results 270 | results = get_token_info_from_session_keys(sessions, options) 271 | # now recollect them as just the access tokens 272 | results.collect { |r| r ? r["access_token"] : nil } 273 | end 274 | 275 | # @deprecated (see #get_token_info_from_session_keys) 276 | def get_token_from_session_key(session, options = {}) 277 | # convenience method for a single key 278 | # gets the overlaoded strings automatically 279 | get_tokens_from_session_keys([session], options)[0] 280 | end 281 | 282 | protected 283 | 284 | def get_token_from_server(args, post = false, options = {}) 285 | # fetch the result from Facebook's servers 286 | response = fetch_token_string(args, post, "access_token", options) 287 | parse_access_token(response) 288 | end 289 | 290 | def parse_access_token(response_text) 291 | components = response_text.split("&").inject({}) do |hash, bit| 292 | key, value = bit.split("=") 293 | hash.merge!(key => value) 294 | end 295 | components 296 | end 297 | 298 | def parse_unsigned_cookie(fb_cookie) 299 | # remove the opening/closing quote 300 | fb_cookie = fb_cookie.gsub(/\"/, "") 301 | 302 | # since we no longer get individual cookies, we have to separate out the components ourselves 303 | components = {} 304 | fb_cookie.split("&").map {|param| param = param.split("="); components[param[0]] = param[1]} 305 | 306 | # generate the signature and make sure it matches what we expect 307 | auth_string = components.keys.sort.collect {|a| a == "sig" ? nil : "#{a}=#{components[a]}"}.reject {|a| a.nil?}.join("") 308 | sig = Digest::MD5.hexdigest(auth_string + @app_secret) 309 | sig == components["sig"] && (components["expires"] == "0" || Time.now.to_i < components["expires"].to_i) ? components : nil 310 | end 311 | 312 | def parse_signed_cookie(fb_cookie) 313 | components = parse_signed_request(fb_cookie) 314 | if code = components["code"] 315 | begin 316 | token_info = get_access_token_info(code, :redirect_uri => '') 317 | rescue Koala::Facebook::OAuthTokenRequestError => err 318 | if err.fb_error_type == 'OAuthException' && err.fb_error_message =~ /Code was invalid or expired/ 319 | return nil 320 | else 321 | raise 322 | end 323 | end 324 | 325 | components.merge(token_info) if token_info 326 | else 327 | Koala::Utils.logger.warn("Signed cookie didn't contain Facebook OAuth code! Components: #{components}") 328 | nil 329 | end 330 | end 331 | 332 | def fetch_token_string(args, post = false, endpoint = "access_token", options = {}) 333 | response = Koala.make_request("/oauth/#{endpoint}", { 334 | :client_id => @app_id, 335 | :client_secret => @app_secret 336 | }.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options)) 337 | 338 | raise ServerError.new(response.status, response.body) if response.status >= 500 339 | raise OAuthTokenRequestError.new(response.status, response.body) if response.status >= 400 340 | 341 | response.body 342 | end 343 | 344 | # base 64 345 | # directly from https://github.com/facebook/crypto-request-examples/raw/master/sample.rb 346 | def base64_url_decode(str) 347 | str += '=' * (4 - str.length.modulo(4)) 348 | Base64.decode64(str.tr('-_', '+/')) 349 | end 350 | 351 | def build_url(base, require_redirect_uri = false, url_options = {}) 352 | if require_redirect_uri && !(url_options[:redirect_uri] ||= url_options.delete(:callback) || @oauth_callback_url) 353 | raise ArgumentError, "build_url must get a callback either from the OAuth object or in the parameters!" 354 | end 355 | 356 | "#{base}?#{Koala::HTTPService.encode_params(url_options)}" 357 | end 358 | end 359 | end 360 | end 361 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | v1.7 2 | ==== 3 | 4 | New methods: 5 | * API#debug_token allows you to examine user tokens (thanks, Cyril-sf!) 6 | * Koala.config allows you to set Facebook servers (to use proxies, etc.) (thanks, bnorton!) 7 | 8 | Internal improvements: 9 | * CHANGED: Parameters can now be Arrays of non-enumerable values, which get comma-separated (thanks, csaunders!) 10 | * CHANGED: API#put_wall_post now automatically encodes parameters hashes as JSON 11 | * CHANGED: GraphCollections returned by batch API calls retain individual access tokens (thanks, billvieux!) 12 | * CHANGED: Gem version restrictions have been removed, and versions updated. 13 | * CHANGED: How support files are loaded in spec_helper has been improved. 14 | * FIXED: API#get_picture returns nil if FB returns no result, rather than error (thanks, mtparet!) 15 | * FIXED: Koala now uses the right grant_type value for fetching app access tokens (thanks, miv!) 16 | * FIXED: Koala now uses the modern OAuth endpoint for generating codes (thanks, jayeff!) 17 | * FIXED: Misc small fixes, typos, etc. (thanks, Ortuna, Crunch09, sagarbommidi!) 18 | 19 | Testing improvements: 20 | * FIXED: MockHTTPService compares Ruby objects rather than strings. 21 | * FIXED: Removed deprecated usage of should_not_receive.and_return (thanks, Cyril-sf!) 22 | * FIXED: Test suite now supports Typhoeus 0.5 (thanks, Cyril-sf!) 23 | * CHANGED: Koala now tests against Ruby 2.0 on Travis (thanks, sanemat!) 24 | 25 | v1.6 26 | ==== 27 | 28 | New methods: 29 | * RealtimeUpdates#validate_update to validate the signature of a Facebook call (thanks, gaffo!) 30 | 31 | Updated methods: 32 | * Graph API methods now accepts a post processing block, see readme for examples (thanks, wolframarnold!) 33 | 34 | Internal improvements: 35 | * Koala now returns more specific and useful error classes (thanks, archfear!) 36 | * Switched URL parsing to addressable, which can handle unusual FB URLs (thanks, bnorton!) 37 | * Fixed Batch API bug that seems to have broken calls requiring post-processing 38 | * Bump Faraday requirement to 0.8 (thanks, jarthod!) 39 | * Picture and video URLs now support unicode characters (thanks, jayeff!) 40 | 41 | Testing improvements: 42 | * Cleaned up some test suites (thanks, bnorton!) 43 | 44 | Documentation: 45 | * Changelog is now markdown 46 | * Code highlighting in readme (thanks, sfate!) 47 | * Added Graph API explorer link in readme (thanks, jch!) 48 | * Added permissions example for OAuth (thanks, sebastiandeutsch!) 49 | 50 | v1.5 51 | ==== 52 | 53 | New methods: 54 | * Added Koala::Utils.logger to enable debugging (thanks, KentonWhite!) 55 | * Expose fb_error_message and fb_error_code directly in APIError 56 | 57 | Updated methods: 58 | * GraphCollection.parse_page_url now uses the URI library and can parse any address (thanks, bnorton!) 59 | 60 | Internal improvements: 61 | * Update MultiJson dependency to support the Oj library (thanks, eckz and zinenko!) 62 | * Loosened Faraday dependency (thanks, rewritten and romanbsd!) 63 | * Fixed typos (thanks, nathanbertram!) 64 | * Switched uses of put_object to the more semantically accurate put_connections 65 | * Cleaned up gemspec 66 | * Handle invalid batch API responses better 67 | 68 | Documentation: 69 | * Added HTTP Services description for Faraday 0.8/persistent connections (thanks, romanbsd!) 70 | * Remove documentation of the old pre-1.2 HTTP Service options 71 | 72 | v1.4.1 73 | ======= 74 | 75 | * Update MultiJson to 1.3 and change syntax to silence warnings (thanks, eckz and masterkain!) 76 | 77 | v1.4 78 | ==== 79 | 80 | New methods: 81 | * OAuth#exchange_access_token(_info) allows you to extend access tokens you receive (thanks, etiennebarrie!) 82 | 83 | Updated methods: 84 | * HTTPServices#encode_params sorts parameters to aid in URL comparison (thanks, sholden!) 85 | * get_connections is now aliased as get_connection (use whichever makes sense to you) 86 | 87 | Internal improvements: 88 | * Fixed typos (thanks, brycethornton and jpemberthy!) 89 | * RealtimeUpdates will no longer execute challenge block unnecessarily (thanks, iainbeeston!) 90 | 91 | Testing improvements: 92 | * Added parallel_tests to development gem file 93 | * Fixed failing live tests 94 | * Koala now tests against JRuby and Rubinius in 1.9 mode on Travis-CI 95 | 96 | v1.3 97 | ==== 98 | 99 | New methods: 100 | * OAuth#url_for_dialog creates URLs for Facebook dialog pages 101 | * API#set_app_restrictions handles JSON-encoding app restrictions 102 | * GraphCollection.parse_page_url now exposes useful functionality for non-Rails apps 103 | * RealtimeUpdates#subscription_path and TestUsers#test_user_accounts_path are now public 104 | 105 | Updated methods: 106 | * REST API methods are now deprecated (see http://developers.facebook.com/blog/post/616/) 107 | * OAuth#url_for_access_token and #url_for_oauth_code now include any provided options as URL parameters 108 | * APIError#raw_response allows access to the raw error response received from Facebook 109 | * Utils.deprecate only prints each message once (no more spamming) 110 | * API#get_page_access_token now accepts additional arguments and HTTP options (like other calls) 111 | * TestUsers and RealtimeUpdates methods now take http_options arguments 112 | * All methods with http_options can now take :http_component => :response for the complete response 113 | * OAuth#get_user_info_from_cookies returns nil rather than an error if the cookies are expired (thanks, herzio) 114 | * TestUsers#delete_all now uses the Batch API and is much faster 115 | 116 | Internal improvements: 117 | * FQL queries now use the Graph API behind-the-scenes 118 | * Cleaned up file and class organization, with aliases for backward compatibility 119 | * Added YARD documentation throughout 120 | * Fixed bugs in RealtimeUpdates, TestUsers, elsewhere 121 | * Reorganized file and class structure non-destructively 122 | 123 | Testing improvements: 124 | * Expanded/improved test coverage 125 | * The test suite no longer users any hard-coded user IDs 126 | * KoalaTest.test_user_api allows access to the TestUsers instance 127 | * Configured tests to run in random order using RSpec 2.8.0rc1 128 | 129 | v1.2.1 130 | ====== 131 | 132 | New methods: 133 | * RestAPI.set_app_properties handles JSON-encoding application properties 134 | 135 | Updated methods: 136 | * OAuth.get_user_from_cookie works with the new signed cookie format (thanks, gmccreight!) 137 | * Beta server URLs are now correct 138 | * OAuth.parse_signed_request now raises an informative error if the signed_request is malformed 139 | 140 | Internal improvements: 141 | * Koala::Multipart middleware properly encoding nested parameters (hashes) in POSTs 142 | * Updated readme, changelog, etc. 143 | 144 | Testing improvements: 145 | * Live tests with test users now clean up all fake users they create 146 | * Removed duplicate test cases 147 | * Live tests with test users no longer delete each object they create, speeding things up 148 | 149 | v1.2 150 | ==== 151 | 152 | New methods: 153 | * API is now the main API class, contains both Graph and REST methods 154 | * Old classes are aliased with deprecation warnings (non-breaking change) 155 | * TestUsers#update lets you update the name or password of an existing test user 156 | * API.get_page_access_token lets you easily fetch the access token for a page you manage (thanks, marcgg!) 157 | * Added version.rb (Koala::VERSION) 158 | 159 | Updated methods: 160 | * OAuth now parses Facebook's new signed cookie format 161 | * API.put_picture now accepts URLs to images (thanks, marcgg!) 162 | * Bug fixes to put_picture, parse_signed_request, and the test suite (thanks, johnbhall and Will S.!) 163 | * Smarter GraphCollection use 164 | * Any pageable result will now become a GraphCollection 165 | * Non-pageable results from get_connections no longer error 166 | * GraphCollection.raw_results allows access to original result data 167 | * Koala no longer enforces any limits on the number of test users you create at once 168 | 169 | Internal improvements: 170 | * Koala now uses Faraday to make requests, replacing the HTTPServices (see wiki) 171 | * Koala::HTTPService.http_options allows specification of default Faraday connection options 172 | * Koala::HTTPService.faraday_middleware allows custom middleware configurations 173 | * Koala now defaults to Net::HTTP rather than Typhoeus 174 | * Koala::NetHTTPService and Koala::TyphoeusService modules no longer exist 175 | * Koala no longer automatically switches to Net::HTTP when uploading IO objects to Facebook 176 | * RealTimeUpdates and TestUsers are no longer subclasses of API, but have their own .api objects 177 | * The old .graph_api accessor is aliases to .api with a deprecation warning 178 | * Removed deprecation warnings for pre-1.1 batch interface 179 | 180 | Testing improvements: 181 | * Live test suites now run against test users by default 182 | * Test suite can be repeatedly run live without having to update facebook_data.yml 183 | * OAuth code and session key tests cannot be run against test users 184 | * Faraday adapter for live tests can be specified with ADAPTER=[your adapter] in the rspec command 185 | * Live tests can be run against the beta server by specifying BETA=true in the rspec command 186 | * Tests now pass against all rubies on Travis CI 187 | * Expanded and refactored test coverage 188 | * Fixed bug with YAML parsing in Ruby 1.9 189 | 190 | v1.1 191 | ==== 192 | 193 | New methods: 194 | * Added Batch API support (thanks, seejohnrun and spiegela!) 195 | * includes file uploads, error handling, and FQL 196 | * Added GraphAPI#put_video 197 | * Added GraphAPI#get_comments_for_urls (thanks, amrnt!) 198 | * Added RestAPI#fql_multiquery, which simplifies the results (thanks, amrnt!) 199 | * HTTP services support global proxy and timeout settings (thanks, itchy!) 200 | * Net::HTTP supports global ca_path, ca_file, and verify_mode settings (thanks, spiegela!) 201 | 202 | Updated methods: 203 | * RealtimeUpdates now uses a GraphAPI object instead of its own API 204 | * RestAPI#rest_call now has an optional last argument for method, for calls requiring POST, DELETE, etc. (thanks, sshilo!) 205 | * Filename can now be specified when uploading (e.g. for Ads API) (thanks, sshilo!) 206 | * get_objects([]) returns [] instead of a Facebook error in non-batch mode (thanks, aselder!) 207 | 208 | Internal improvements: 209 | * Koala is now more compatible with other Rubies (JRuby, Rubinius, etc.) 210 | * HTTP services are more modular and can be changed on the fly (thanks, chadk!) 211 | * Includes support for uploading StringIOs and other non-files via Net::HTTP even when using TyphoeusService 212 | * Koala now uses multi_json to improve compatibility with Rubinius and other Ruby versions 213 | * Koala now uses the modern Typhoeus API (thanks, aselder!) 214 | * Koala now uses the current modern Net::HTTP interface (thanks, romanbsd!) 215 | * Fixed bugs and typos (thanks, waynn, mokevnin, and tikh!) 216 | 217 | v1.0 218 | ==== 219 | 220 | New methods: 221 | * Photo and file upload now supported through #put_picture 222 | * Added UploadableIO class to manage file uploads 223 | * Added a delete_like method (thanks to waseem) 224 | * Added put_connection and delete_connection convenience methods 225 | 226 | Updated methods: 227 | * Search can now search places, checkins, etc. (thanks, rickyc!) 228 | * You can now pass :beta => true in the http options to use Facebook's beta tier 229 | * TestUser#befriend now requires user info hashes (id and access token) due to Facebook API changes (thanks, pulsd and kbighorse!) 230 | * All methods now accept an http_options hash as their optional last parameter (thanks, spiegela!) 231 | * url_for_oauth_code can now take a :display option (thanks, netbe!) 232 | * Net::HTTP can now accept :timeout and :proxy options (thanks, gilles!) 233 | * Test users now supports using test accounts across multiple apps 234 | 235 | Internal improvements: 236 | * For public requests, Koala now uses http by default (instead of https) to improve speed 237 | * This can be overridden through Koala.always_use_ssl= or by passing :use_ssl => true in the options hash for an api call 238 | * Read-only REST API requests now go through the faster api-read server 239 | * Replaced parse_signed_request with a version from Facebook that also supports the new signed params proposal 240 | * Note: invalid requests will now raise exceptions rather than return nil, in keeping with other SDKs 241 | * Delete methods will now raise an error if there's no access token (like put_object and delete_like) 242 | * Updated parse_signed_request to match Facebook's current implementation (thanks, imajes!) 243 | * APIError is now < StandardError, not Exception 244 | * Added KoalaError for non-API errors 245 | * Net::HTTP's SSL verification is no longer disabled by default 246 | 247 | Test improvements: 248 | * Incorporated joshk's awesome rewrite of the entire Koala test suite (thanks, joshk!) 249 | * Expanded HTTP service tests (added Typhoeus test suite and additional Net::HTTP test cases) 250 | * Live tests now verify that the access token has the necessary permissions before starting 251 | * Replaced the 50-person network test, which often took 15+ minutes to run live, with a 5-person test 252 | 253 | v0.10.0 254 | ======= 255 | 256 | * Added test user module 257 | * Fixed bug when raising APIError after Facebook fails to exchange session keys 258 | * Made access_token accessible via the readonly access_token property on all our API classes 259 | 260 | v0.9.1 261 | ====== 262 | 263 | * Tests are now compatible with Ruby 1.9.2 264 | * Added JSON to runtime dependencies 265 | * Removed examples directory (referenced from github instead) 266 | 267 | v0.9.0 268 | ====== 269 | 270 | * Added parse_signed_request to handle Facebook's new authentication scheme 271 | * note: creates dependency on OpenSSL (OpenSSL::HMAC) for decryption 272 | * Added GraphCollection class to provide paging support for GraphAPI get_connections and search methods (thanks to jagthedrummer) 273 | * Added get_page method to easily fetch pages of results from GraphCollections 274 | * Exchanging sessions for tokens now works properly when provided invalid/expired session keys 275 | * You can now include a :typhoeus_options key in TyphoeusService#make_request's options hash to control the Typhoeus call (for example, to set :disable_ssl_peer_verification => true) 276 | * All paths provided to HTTP services start with leading / to improve compatibility with stubbing libraries 277 | * If Facebook returns nil for search or get_connections requests, Koala now returns nil rather than throwing an exception 278 | 279 | v0.8.0 280 | ====== 281 | 282 | * Breaking interface changes 283 | * Removed string overloading for the methods, per 0.7.3, which caused Marshaling issues 284 | * Removed ability to provide a string as the second argument to url_for_access_token, per 0.5.0 285 | 286 | v0.7.4 287 | ====== 288 | 289 | * Fixed bug with get_user_from_cookies 290 | 291 | v0.7.3 292 | ====== 293 | 294 | * Added support for picture sizes -- thanks thhermansen for the patch! 295 | * Adjusted the return values for several methods (get_access_token, get_app_access_token, get_token_from_session_key, get_tokens_from_session_keys, get_user_from_cookies) 296 | * These methods now return strings, rather than hashes, which makes more sense 297 | * The strings are overloaded with an [] method for backwards compatibility (Ruby is truly amazing) 298 | * Using those methods triggers a deprecation warning 299 | * This will be removed by 1.0 300 | * There are new info methods (get_access_token_info, get_app_access_token_info, get_token_info_from_session_keys, and get_user_info_from_cookies) that natively return hashes, for when you want the expiration date 301 | * Responses with HTTP status 500+ now properly throw errors under Net::HTTP 302 | * Updated changelog 303 | * Added license 304 | 305 | v0.7.2 306 | ====== 307 | 308 | * Added support for exchanging session keys for OAuth access tokens (get_token_from_session_key for single keys, get_tokens_from_session_keys for multiple) 309 | * Moved Koala files into a koala/ subdirectory to minimize risk of name collisions 310 | * Added OAuth Playground git submodule as an example 311 | * Updated tests, readme, and changelog 312 | 313 | v0.7.1 314 | ====== 315 | 316 | * Updated RealtimeUpdates#list_subscriptions and GraphAPI#get_connections to now return an 317 | array of results directly (rather than a hash with one key) 318 | * Fixed a bug with Net::HTTP-based HTTP service in which the headers hash was improperly formatted 319 | * Updated readme 320 | 321 | v0.7.0 322 | ====== 323 | 324 | * Added RealtimeUpdates class, which can be used to manage subscriptions for user updates (see http://developers.facebook.com/docs/api/realtime) 325 | * Added picture method to graph API, which fetches an object's picture from the redirect headers. 326 | * Added _greatly_ improved testing with result mocking, which is now the default set of tests 327 | * Renamed live testing spec to koala_spec_without_mocks.rb 328 | * Added Koala::Response class, which encapsulates HTTP results since Facebook sometimes sends data in the status or headers 329 | * Much internal refactoring 330 | * Updated readme, changelog, etc. 331 | 332 | v0.6.0 333 | ====== 334 | 335 | * Added support for the old REST API thanks to cbaclig's great work 336 | * Updated tests to conform to RSpec standards 337 | * Updated changelog, readme, etc. 338 | 339 | v0.5.1 340 | ====== 341 | 342 | * Documentation is now on the wiki, updated readme accordingly. 343 | 344 | v0.5.0 345 | ====== 346 | 347 | * Added several new OAuth methods for making and parsing access token requests 348 | * Added test suite for the OAuth class 349 | * Made second argument to url_for_access_token a hash (strings still work but trigger a deprecation warning) 350 | * Added fields to facebook_data.yml 351 | * Updated readme 352 | 353 | v0.4.1 354 | ====== 355 | 356 | * Encapsulated GraphAPI and OAuth classes in the Koala::Facebook module for clarity (and to avoid claiming the global Facebook class) 357 | * Moved make_request method to Koala class from GraphAPI instance (for use by future OAuth class functionality) 358 | * Renamed request method to api for consistancy with Javascript library 359 | * Updated tests and readme 360 | 361 | v0.4.0 362 | ====== 363 | 364 | * Adopted the Koala name 365 | * Updated readme and tests 366 | * Fixed cookie verification bug for non-expiring OAuth tokens 367 | 368 | v0.3.1 369 | ====== 370 | 371 | * Bug fixes. 372 | 373 | v0.3 374 | ==== 375 | 376 | * Renamed Graph API class from Facebook::GraphAPI to FacebookGraph::API 377 | * Created FacebookGraph::OAuth class for tokens and OAuth URLs 378 | * Updated method for including HTTP service (think we've got it this time) 379 | * Updated tests 380 | * Added CHANGELOG and gemspec 381 | 382 | v0.2 383 | ==== 384 | 385 | * Gemified the project 386 | * Split out HTTP services into their own file, and adjusted inclusion method 387 | 388 | v0.1 389 | ==== 390 | 391 | * Added modular support for Typhoeus 392 | * Added tests 393 | 394 | v0.0 395 | ==== 396 | 397 | * Hi from F8! Basic read/write from the graph is working 398 | --------------------------------------------------------------------------------