koala 1.5.0 → 1.6.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -1
- data/.travis.yml +4 -1
- data/Gemfile +1 -1
- data/changelog.md +293 -0
- data/koala.gemspec +3 -2
- data/lib/koala.rb +1 -2
- data/lib/koala/api.rb +11 -31
- data/lib/koala/api/batch_operation.rb +1 -1
- data/lib/koala/api/graph_api.rb +132 -62
- data/lib/koala/api/graph_batch_api.rb +28 -38
- data/lib/koala/api/graph_collection.rb +3 -1
- data/lib/koala/api/rest_api.rb +19 -3
- data/lib/koala/errors.rb +86 -0
- data/lib/koala/oauth.rb +21 -21
- data/lib/koala/realtime_updates.rb +42 -21
- data/lib/koala/version.rb +1 -1
- data/readme.md +130 -103
- data/spec/cases/api_spec.rb +3 -3
- data/spec/cases/error_spec.rb +91 -20
- data/spec/cases/graph_api_batch_spec.rb +57 -22
- data/spec/cases/graph_api_spec.rb +68 -0
- data/spec/cases/graph_collection_spec.rb +6 -0
- data/spec/cases/oauth_spec.rb +16 -16
- data/spec/cases/realtime_updates_spec.rb +80 -82
- data/spec/cases/test_users_spec.rb +21 -18
- data/spec/fixtures/mock_facebook_responses.yml +45 -29
- data/spec/spec_helper.rb +6 -6
- data/spec/support/graph_api_shared_examples.rb +13 -13
- data/spec/support/koala_test.rb +13 -13
- data/spec/support/rest_api_shared_examples.rb +3 -3
- metadata +30 -14
- data/CHANGELOG +0 -275
@@ -19,35 +19,23 @@ module Koala
|
|
19
19
|
end
|
20
20
|
|
21
21
|
def graph_call_in_batch(path, args = {}, verb = "get", options = {}, &post_processing)
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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
32
|
end
|
33
33
|
|
34
|
-
|
35
|
-
|
36
|
-
# old error format -- see http://developers.facebook.com/blog/post/596/
|
37
|
-
APIError.new({"type" => "Error #{response["error"]}", "message" => response["error_description"]}.merge(response))
|
38
|
-
else
|
39
|
-
check_graph_api_response(response)
|
40
|
-
end
|
41
|
-
end
|
42
|
-
|
43
|
-
# redefine the graph_call and check_response methods
|
44
|
-
# so we can use this API inside the batch block just like any regular Graph API
|
34
|
+
# redefine the graph_call method so we can use this API inside the batch block
|
35
|
+
# just like any regular Graph API
|
45
36
|
alias_method :graph_call_outside_batch, :graph_call
|
46
37
|
alias_method :graph_call, :graph_call_in_batch
|
47
38
|
|
48
|
-
alias_method :check_graph_api_response, :check_response
|
49
|
-
alias_method :check_response, :check_graph_batch_api_response
|
50
|
-
|
51
39
|
# execute the queued batch calls
|
52
40
|
def execute(http_options = {})
|
53
41
|
return [] unless batch_calls.length > 0
|
@@ -62,7 +50,7 @@ module Koala
|
|
62
50
|
unless response
|
63
51
|
# Facebook sometimes reportedly returns an empty body at times
|
64
52
|
# see https://github.com/arsduo/koala/issues/184
|
65
|
-
raise
|
53
|
+
raise BadFacebookResponse.new(200, '', "Facebook returned an empty body")
|
66
54
|
end
|
67
55
|
|
68
56
|
# map the results with post-processing included
|
@@ -72,13 +60,16 @@ module Koala
|
|
72
60
|
batch_op = batch_calls[index]
|
73
61
|
index += 1
|
74
62
|
|
63
|
+
raw_result = nil
|
75
64
|
if call_result
|
76
|
-
|
77
|
-
|
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]
|
78
70
|
|
79
|
-
unless call_result["code"].to_i >= 500 || error = check_response(body)
|
80
71
|
# Get the HTTP component they want
|
81
|
-
|
72
|
+
raw_result = case batch_op.http_options[:http_component]
|
82
73
|
when :status
|
83
74
|
call_result["code"].to_i
|
84
75
|
when :headers
|
@@ -87,20 +78,19 @@ module Koala
|
|
87
78
|
else
|
88
79
|
body
|
89
80
|
end
|
90
|
-
|
91
|
-
# process it if we are given a block to process with
|
92
|
-
batch_op.post_processing ? batch_op.post_processing.call(data) : data
|
93
|
-
else
|
94
|
-
error || APIError.new({"type" => "HTTP #{call_result["code"].to_s}", "message" => "Response body: #{body}"})
|
95
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)
|
96
89
|
else
|
97
|
-
|
90
|
+
result
|
98
91
|
end
|
99
92
|
end
|
100
93
|
end
|
101
|
-
|
102
|
-
# turn any results that are pageable into GraphCollections
|
103
|
-
batch_result.inject([]) {|processed_results, raw| processed_results << GraphCollection.evaluate(raw, @original_api)}
|
104
94
|
end
|
105
95
|
|
106
96
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'addressable/uri'
|
2
|
+
|
1
3
|
module Koala
|
2
4
|
module Facebook
|
3
5
|
class API
|
@@ -87,7 +89,7 @@ module Koala
|
|
87
89
|
#
|
88
90
|
# @return an array of parameters that can be provided via graph_call(*parsed_params)
|
89
91
|
def self.parse_page_url(url)
|
90
|
-
uri = URI.parse(url)
|
92
|
+
uri = Addressable::URI.parse(url)
|
91
93
|
|
92
94
|
base = uri.path.sub(/^\//, '')
|
93
95
|
params = CGI.parse(uri.query)
|
data/lib/koala/api/rest_api.rb
CHANGED
@@ -21,7 +21,7 @@ module Koala
|
|
21
21
|
#
|
22
22
|
# @return true if successful, false if not. (This call currently doesn't give useful feedback on failure.)
|
23
23
|
def set_app_properties(properties, args = {}, options = {})
|
24
|
-
raise
|
24
|
+
raise AuthenticationError.new(nil, nil, "setAppProperties requires an access token") unless @access_token
|
25
25
|
rest_call("admin.setAppProperties", args.merge(:properties => MultiJson.dump(properties)), options, "post")
|
26
26
|
end
|
27
27
|
|
@@ -44,8 +44,24 @@ module Koala
|
|
44
44
|
|
45
45
|
api("method/#{fb_method}", args.merge('format' => 'json'), verb, options) do |response|
|
46
46
|
# check for REST API-specific errors
|
47
|
-
if response.
|
48
|
-
|
47
|
+
if response.status >= 400
|
48
|
+
begin
|
49
|
+
response_hash = MultiJson.load(response.body)
|
50
|
+
rescue MultiJson::DecodeError
|
51
|
+
response_hash = {}
|
52
|
+
end
|
53
|
+
|
54
|
+
error_info = {
|
55
|
+
'code' => response_hash['error_code'],
|
56
|
+
'error_subcode' => response_hash['error_subcode'],
|
57
|
+
'message' => response_hash['error_msg']
|
58
|
+
}
|
59
|
+
|
60
|
+
if response.status >= 500
|
61
|
+
raise ServerError.new(response.status, response.body, error_info)
|
62
|
+
else
|
63
|
+
raise ClientError.new(response.status, response.body, error_info)
|
64
|
+
end
|
49
65
|
end
|
50
66
|
end
|
51
67
|
end
|
data/lib/koala/errors.rb
ADDED
@@ -0,0 +1,86 @@
|
|
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
|
data/lib/koala/oauth.rb
CHANGED
@@ -139,7 +139,7 @@ module Koala
|
|
139
139
|
# @param code (see #url_for_access_token)
|
140
140
|
# @param options any additional parameters to send to Facebook when redeeming the token
|
141
141
|
#
|
142
|
-
# @raise Koala::Facebook::
|
142
|
+
# @raise Koala::Facebook::OAuthTokenRequestError if Facebook returns an error response
|
143
143
|
#
|
144
144
|
# @return a hash of the access token info returned by Facebook (token, expiration, etc.)
|
145
145
|
def get_access_token_info(code, options = {})
|
@@ -221,21 +221,21 @@ module Koala
|
|
221
221
|
#
|
222
222
|
# @param input the signed request from Facebook
|
223
223
|
#
|
224
|
-
# @raise
|
224
|
+
# @raise OAuthSignatureError if the signature is incomplete, invalid, or using an unsupported algorithm
|
225
225
|
#
|
226
226
|
# @return a hash of the validated request information
|
227
227
|
def parse_signed_request(input)
|
228
228
|
encoded_sig, encoded_envelope = input.split('.', 2)
|
229
|
-
raise '
|
229
|
+
raise OAuthSignatureError, 'Invalid (incomplete) signature data' unless encoded_sig && encoded_envelope
|
230
230
|
|
231
231
|
signature = base64_url_decode(encoded_sig).unpack("H*").first
|
232
232
|
envelope = MultiJson.load(base64_url_decode(encoded_envelope))
|
233
233
|
|
234
|
-
raise "
|
234
|
+
raise OAuthSignatureError, "Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
|
235
235
|
|
236
236
|
# now see if the signature is valid (digest, key, data)
|
237
237
|
hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @app_secret, encoded_envelope)
|
238
|
-
raise '
|
238
|
+
raise OAuthSignatureError, 'Invalid signature' if (signature != hmac)
|
239
239
|
|
240
240
|
envelope
|
241
241
|
end
|
@@ -254,10 +254,7 @@ module Koala
|
|
254
254
|
|
255
255
|
# Facebook returns an empty body in certain error conditions
|
256
256
|
if response == ""
|
257
|
-
raise
|
258
|
-
"type" => "ArgumentError",
|
259
|
-
"message" => "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!"
|
260
|
-
})
|
257
|
+
raise BadFacebookResponse.new(200, '', "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!")
|
261
258
|
end
|
262
259
|
|
263
260
|
MultiJson.load(response)
|
@@ -282,13 +279,8 @@ module Koala
|
|
282
279
|
|
283
280
|
def get_token_from_server(args, post = false, options = {})
|
284
281
|
# fetch the result from Facebook's servers
|
285
|
-
|
286
|
-
|
287
|
-
# if we have an error, parse the error JSON and raise an error
|
288
|
-
raise APIError.new((MultiJson.load(result)["error"] rescue nil) || {}) if result =~ /error/
|
289
|
-
|
290
|
-
# otherwise, parse the access token
|
291
|
-
parse_access_token(result)
|
282
|
+
response = fetch_token_string(args, post, "access_token", options)
|
283
|
+
parse_access_token(response)
|
292
284
|
end
|
293
285
|
|
294
286
|
def parse_access_token(response_text)
|
@@ -318,9 +310,12 @@ module Koala
|
|
318
310
|
if code = components["code"]
|
319
311
|
begin
|
320
312
|
token_info = get_access_token_info(code, :redirect_uri => '')
|
321
|
-
rescue Koala::Facebook::
|
322
|
-
|
323
|
-
|
313
|
+
rescue Koala::Facebook::OAuthTokenRequestError => err
|
314
|
+
if err.fb_error_type == 'OAuthException' && err.fb_error_message =~ /Code was invalid or expired/
|
315
|
+
return nil
|
316
|
+
else
|
317
|
+
raise
|
318
|
+
end
|
324
319
|
end
|
325
320
|
|
326
321
|
components.merge(token_info) if token_info
|
@@ -331,10 +326,15 @@ module Koala
|
|
331
326
|
end
|
332
327
|
|
333
328
|
def fetch_token_string(args, post = false, endpoint = "access_token", options = {})
|
334
|
-
Koala.make_request("/oauth/#{endpoint}", {
|
329
|
+
response = Koala.make_request("/oauth/#{endpoint}", {
|
335
330
|
:client_id => @app_id,
|
336
331
|
:client_secret => @app_secret
|
337
|
-
}.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options))
|
332
|
+
}.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options))
|
333
|
+
|
334
|
+
raise ServerError.new(response.status, response.body) if response.status >= 500
|
335
|
+
raise OAuthTokenRequestError.new(response.status, response.body) if response.status >= 400
|
336
|
+
|
337
|
+
response.body
|
338
338
|
end
|
339
339
|
|
340
340
|
# base 64
|
@@ -7,19 +7,19 @@ module Koala
|
|
7
7
|
# @note: to subscribe to real-time updates, you must have an application access token
|
8
8
|
# or provide the app secret when initializing your RealtimeUpdates object.
|
9
9
|
|
10
|
-
# The application API interface used to communicate with Facebook.
|
11
|
-
# @return [Koala::Facebook::API]
|
10
|
+
# The application API interface used to communicate with Facebook.
|
11
|
+
# @return [Koala::Facebook::API]
|
12
12
|
attr_reader :api
|
13
13
|
attr_reader :app_id, :app_access_token, :secret
|
14
14
|
|
15
|
-
# Create a new RealtimeUpdates instance.
|
16
|
-
# If you don't have your app's access token, provide the app's secret and
|
15
|
+
# Create a new RealtimeUpdates instance.
|
16
|
+
# If you don't have your app's access token, provide the app's secret and
|
17
17
|
# Koala will make a request to Facebook for the appropriate token.
|
18
|
-
#
|
18
|
+
#
|
19
19
|
# @param options initialization options.
|
20
20
|
# @option options :app_id the application's ID.
|
21
21
|
# @option options :app_access_token an application access token, if known.
|
22
|
-
# @option options :secret the application's secret.
|
22
|
+
# @option options :secret the application's secret.
|
23
23
|
#
|
24
24
|
# @raise ArgumentError if the application ID and one of the app access token or the secret are not provided.
|
25
25
|
def initialize(options = {})
|
@@ -39,7 +39,7 @@ module Koala
|
|
39
39
|
@api = API.new(@app_access_token)
|
40
40
|
end
|
41
41
|
|
42
|
-
# Subscribe to realtime updates for certain fields on a given object (user, page, etc.).
|
42
|
+
# Subscribe to realtime updates for certain fields on a given object (user, page, etc.).
|
43
43
|
# See {http://developers.facebook.com/docs/reference/api/realtime the realtime updates documentation}
|
44
44
|
# for more information on what objects and fields you can register for.
|
45
45
|
#
|
@@ -48,11 +48,11 @@ module Koala
|
|
48
48
|
# @param object a Facebook ID (name or number)
|
49
49
|
# @param fields the fields you want your app to be updated about
|
50
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
|
51
|
+
# @param verify_token a token included in the verification request, allowing you to ensure the call is genuine
|
52
52
|
# (see the docs for more information)
|
53
53
|
# @param options (see Koala::HTTPService.make_request)
|
54
54
|
#
|
55
|
-
# @
|
55
|
+
# @raise A subclass of Koala::Facebook::APIError if the subscription request failed.
|
56
56
|
def subscribe(object, fields, callback_url, verify_token, options = {})
|
57
57
|
args = {
|
58
58
|
:object => object,
|
@@ -60,23 +60,23 @@ module Koala
|
|
60
60
|
:callback_url => callback_url,
|
61
61
|
}.merge(verify_token ? {:verify_token => verify_token} : {})
|
62
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
|
63
|
+
@api.graph_call(subscription_path, args, 'post', options)
|
64
64
|
end
|
65
65
|
|
66
|
-
# Unsubscribe from updates for a particular object or from updates.
|
66
|
+
# Unsubscribe from updates for a particular object or from updates.
|
67
67
|
#
|
68
|
-
# @param object the object whose subscriptions to delete.
|
68
|
+
# @param object the object whose subscriptions to delete.
|
69
69
|
# If no object is provided, all subscriptions will be removed.
|
70
70
|
# @param options (see Koala::HTTPService.make_request)
|
71
71
|
#
|
72
|
-
# @
|
72
|
+
# @raise A subclass of Koala::Facebook::APIError if the subscription request failed.
|
73
73
|
def unsubscribe(object = nil, options = {})
|
74
|
-
@api.graph_call(subscription_path, object ? {:object => object} : {}, "delete", options
|
74
|
+
@api.graph_call(subscription_path, object ? {:object => object} : {}, "delete", options)
|
75
75
|
end
|
76
76
|
|
77
77
|
# List all active subscriptions for this application.
|
78
|
-
#
|
79
|
-
# @param options (see Koala::HTTPService.make_request)
|
78
|
+
#
|
79
|
+
# @param options (see Koala::HTTPService.make_request)
|
80
80
|
#
|
81
81
|
# @return [Array] a list of active subscriptions
|
82
82
|
def list_subscriptions(options = {})
|
@@ -89,12 +89,12 @@ module Koala
|
|
89
89
|
#
|
90
90
|
# @param params the request parameters sent by Facebook. (You can pass in a Rails params hash.)
|
91
91
|
# @param verify_token the verify token sent in the {#subscribe subscription request}, if you provided one
|
92
|
-
#
|
92
|
+
#
|
93
93
|
# @yield verify_token if you need to compute the verification token
|
94
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
|
95
|
+
# and use to calculate a hash), you can pass meet_challenge a block, which
|
96
96
|
# will receive the verify_token received back from Facebook.
|
97
|
-
#
|
97
|
+
#
|
98
98
|
# @return the challenge string to be sent back to Facebook, or false if the request is invalid.
|
99
99
|
def self.meet_challenge(params, verify_token = nil, &verification_block)
|
100
100
|
if params["hub.mode"] == "subscribe" &&
|
@@ -111,12 +111,33 @@ module Koala
|
|
111
111
|
false
|
112
112
|
end
|
113
113
|
end
|
114
|
-
|
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
|
+
|
115
136
|
# The Facebook subscription management URL for your application.
|
116
137
|
def subscription_path
|
117
138
|
@subscription_path ||= "#{@app_id}/subscriptions"
|
118
139
|
end
|
119
|
-
|
140
|
+
|
120
141
|
# @private
|
121
142
|
def graph_api
|
122
143
|
Koala::Utils.deprecate("the TestUsers.graph_api accessor is deprecated and will be removed in a future version; please use .api instead.")
|