├── .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 | [](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 |
--------------------------------------------------------------------------------