restforce 3.0.1 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/.circleci/config.yml +9 -9
- data/.rubocop.yml +10 -12
- data/.rubocop_todo.yml +128 -81
- data/CHANGELOG.md +30 -1
- data/Gemfile +2 -1
- data/README.md +124 -12
- data/lib/restforce.rb +22 -1
- data/lib/restforce/abstract_client.rb +1 -0
- data/lib/restforce/attachment.rb +1 -0
- data/lib/restforce/concerns/api.rb +9 -6
- data/lib/restforce/concerns/authentication.rb +10 -0
- data/lib/restforce/concerns/base.rb +2 -0
- data/lib/restforce/concerns/batch_api.rb +87 -0
- data/lib/restforce/concerns/canvas.rb +1 -0
- data/lib/restforce/concerns/picklists.rb +2 -1
- data/lib/restforce/concerns/streaming.rb +75 -3
- data/lib/restforce/config.rb +4 -0
- data/lib/restforce/document.rb +1 -0
- data/lib/restforce/middleware/authentication.rb +3 -2
- data/lib/restforce/middleware/authentication/jwt_bearer.rb +38 -0
- data/lib/restforce/middleware/multipart.rb +1 -0
- data/lib/restforce/middleware/raise_error.rb +24 -8
- data/lib/restforce/signed_request.rb +1 -0
- data/lib/restforce/sobject.rb +1 -0
- data/lib/restforce/tooling/client.rb +3 -3
- data/lib/restforce/version.rb +1 -1
- data/restforce.gemspec +8 -7
- data/spec/fixtures/test_private.key +27 -0
- data/spec/integration/abstract_client_spec.rb +42 -1
- data/spec/support/fixture_helpers.rb +2 -2
- data/spec/unit/concerns/authentication_spec.rb +35 -0
- data/spec/unit/concerns/batch_api_spec.rb +107 -0
- data/spec/unit/concerns/streaming_spec.rb +144 -4
- data/spec/unit/middleware/authentication/jwt_bearer_spec.rb +62 -0
- data/spec/unit/middleware/raise_error_spec.rb +32 -11
- metadata +53 -32
data/lib/restforce.rb
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
require 'faraday'
|
4
4
|
require 'faraday_middleware'
|
5
5
|
require 'json'
|
6
|
+
require 'jwt'
|
6
7
|
|
7
8
|
require 'restforce/version'
|
8
9
|
require 'restforce/config'
|
@@ -29,6 +30,7 @@ module Restforce
|
|
29
30
|
autoload :Verbs, 'restforce/concerns/verbs'
|
30
31
|
autoload :Base, 'restforce/concerns/base'
|
31
32
|
autoload :API, 'restforce/concerns/api'
|
33
|
+
autoload :BatchAPI, 'restforce/concerns/batch_api'
|
32
34
|
end
|
33
35
|
|
34
36
|
module Data
|
@@ -44,6 +46,25 @@ module Restforce
|
|
44
46
|
AuthenticationError = Class.new(Error)
|
45
47
|
UnauthorizedError = Class.new(Error)
|
46
48
|
APIVersionError = Class.new(Error)
|
49
|
+
BatchAPIError = Class.new(Error)
|
50
|
+
|
51
|
+
# Inherit from Faraday::Error::ResourceNotFound for backwards-compatibility
|
52
|
+
# Consumers of this library that rescue and handle Faraday::Error::ResourceNotFound
|
53
|
+
# can continue to do so.
|
54
|
+
NotFoundError = Class.new(Faraday::Error::ResourceNotFound)
|
55
|
+
|
56
|
+
# Inherit from Faraday::Error::ClientError for backwards-compatibility
|
57
|
+
# Consumers of this library that rescue and handle Faraday::Error::ClientError
|
58
|
+
# can continue to do so.
|
59
|
+
ResponseError = Class.new(Faraday::Error::ClientError)
|
60
|
+
MatchesMultipleError= Class.new(ResponseError)
|
61
|
+
EntityTooLargeError = Class.new(ResponseError)
|
62
|
+
|
63
|
+
module ErrorCode
|
64
|
+
def self.const_missing(constant_name)
|
65
|
+
const_set constant_name, Class.new(ResponseError)
|
66
|
+
end
|
67
|
+
end
|
47
68
|
|
48
69
|
class << self
|
49
70
|
# Alias for Restforce::Data::Client.new
|
@@ -74,7 +95,7 @@ module Restforce
|
|
74
95
|
self
|
75
96
|
end
|
76
97
|
end
|
77
|
-
Object.
|
98
|
+
Object.include Restforce::CoreExtensions unless Object.respond_to? :tap
|
78
99
|
end
|
79
100
|
|
80
101
|
if ENV['PROXY_URI']
|
data/lib/restforce/attachment.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'erb'
|
3
4
|
require 'uri'
|
4
5
|
require 'restforce/concerns/verbs'
|
5
6
|
|
@@ -320,6 +321,7 @@ module Restforce
|
|
320
321
|
def update!(sobject, attrs)
|
321
322
|
id = attrs.fetch(attrs.keys.find { |k, v| k.to_s.casecmp('id').zero? }, nil)
|
322
323
|
raise ArgumentError, 'ID field missing from provided attributes' unless id
|
324
|
+
|
323
325
|
attrs_without_id = attrs.reject { |k, v| k.to_s.casecmp("id").zero? }
|
324
326
|
api_patch "sobjects/#{sobject}/#{CGI.escape(id)}", attrs_without_id
|
325
327
|
true
|
@@ -377,7 +379,8 @@ module Restforce
|
|
377
379
|
api_post "sobjects/#{sobject}/#{field}", attrs
|
378
380
|
end
|
379
381
|
else
|
380
|
-
api_patch "sobjects/#{sobject}/#{field}
|
382
|
+
api_patch "sobjects/#{sobject}/#{field}/" \
|
383
|
+
"#{ERB::Util.url_encode(external_id)}", attrs
|
381
384
|
end
|
382
385
|
|
383
386
|
response.body.respond_to?(:fetch) ? response.body.fetch('id', true) : true
|
@@ -414,7 +417,7 @@ module Restforce
|
|
414
417
|
# Returns true of the sobject was successfully deleted.
|
415
418
|
# Raises an exception if an error is returned from Salesforce.
|
416
419
|
def destroy!(sobject, id)
|
417
|
-
api_delete "sobjects/#{sobject}/#{
|
420
|
+
api_delete "sobjects/#{sobject}/#{ERB::Util.url_encode(id)}"
|
418
421
|
true
|
419
422
|
end
|
420
423
|
|
@@ -428,9 +431,9 @@ module Restforce
|
|
428
431
|
# Returns the Restforce::SObject sobject record.
|
429
432
|
def find(sobject, id, field = nil)
|
430
433
|
url = if field
|
431
|
-
"sobjects/#{sobject}/#{field}/#{
|
434
|
+
"sobjects/#{sobject}/#{field}/#{ERB::Util.url_encode(id)}"
|
432
435
|
else
|
433
|
-
"sobjects/#{sobject}/#{
|
436
|
+
"sobjects/#{sobject}/#{ERB::Util.url_encode(id)}"
|
434
437
|
end
|
435
438
|
api_get(url).body
|
436
439
|
end
|
@@ -446,9 +449,9 @@ module Restforce
|
|
446
449
|
#
|
447
450
|
def select(sobject, id, select, field = nil)
|
448
451
|
path = if field
|
449
|
-
"sobjects/#{sobject}/#{field}/#{
|
452
|
+
"sobjects/#{sobject}/#{field}/#{ERB::Util.url_encode(id)}"
|
450
453
|
else
|
451
|
-
"sobjects/#{sobject}/#{
|
454
|
+
"sobjects/#{sobject}/#{ERB::Util.url_encode(id)}"
|
452
455
|
end
|
453
456
|
|
454
457
|
path = "#{path}?fields=#{select.join(',')}" if select&.any?
|
@@ -19,6 +19,8 @@ module Restforce
|
|
19
19
|
Restforce::Middleware::Authentication::Password
|
20
20
|
elsif oauth_refresh?
|
21
21
|
Restforce::Middleware::Authentication::Token
|
22
|
+
elsif jwt?
|
23
|
+
Restforce::Middleware::Authentication::JWTBearer
|
22
24
|
end
|
23
25
|
end
|
24
26
|
|
@@ -38,6 +40,14 @@ module Restforce
|
|
38
40
|
options[:client_id] &&
|
39
41
|
options[:client_secret]
|
40
42
|
end
|
43
|
+
|
44
|
+
# Internal: Returns true if jwt bearer token flow should be used for
|
45
|
+
# authentication.
|
46
|
+
def jwt?
|
47
|
+
options[:jwt_key] &&
|
48
|
+
options[:username] &&
|
49
|
+
options[:client_id]
|
50
|
+
end
|
41
51
|
end
|
42
52
|
end
|
43
53
|
end
|
@@ -28,6 +28,8 @@ module Restforce
|
|
28
28
|
# password and oauth authentication
|
29
29
|
# :client_secret - The oauth client secret to use.
|
30
30
|
#
|
31
|
+
# :jwt_key - The private key for JWT authentication
|
32
|
+
#
|
31
33
|
# :host - The String hostname to use during
|
32
34
|
# authentication requests
|
33
35
|
# (default: 'login.salesforce.com').
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'restforce/concerns/verbs'
|
4
|
+
|
5
|
+
module Restforce
|
6
|
+
module Concerns
|
7
|
+
module BatchAPI
|
8
|
+
extend Restforce::Concerns::Verbs
|
9
|
+
|
10
|
+
define_verbs :post
|
11
|
+
|
12
|
+
def batch(halt_on_error: false)
|
13
|
+
subrequests = Subrequests.new(options)
|
14
|
+
yield(subrequests)
|
15
|
+
subrequests.requests.each_slice(25).map do |requests|
|
16
|
+
properties = {
|
17
|
+
batchRequests: requests,
|
18
|
+
haltOnError: halt_on_error
|
19
|
+
}
|
20
|
+
response = api_post('composite/batch', properties.to_json)
|
21
|
+
body = response.body
|
22
|
+
results = body['results']
|
23
|
+
if halt_on_error && body['hasErrors']
|
24
|
+
last_error_index = results.rindex { |result| result['statusCode'] != 412 }
|
25
|
+
last_error = results[last_error_index]
|
26
|
+
raise BatchAPIError, last_error['result'][0]['errorCode']
|
27
|
+
end
|
28
|
+
results.map(&:compact)
|
29
|
+
end.flatten
|
30
|
+
end
|
31
|
+
|
32
|
+
def batch!(&block)
|
33
|
+
batch(halt_on_error: true, &block)
|
34
|
+
end
|
35
|
+
|
36
|
+
class Subrequests
|
37
|
+
def initialize(options)
|
38
|
+
@options = options
|
39
|
+
@requests = []
|
40
|
+
end
|
41
|
+
attr_reader :options, :requests
|
42
|
+
|
43
|
+
def create(sobject, attrs)
|
44
|
+
requests << { method: 'POST', url: batch_api_path(sobject), richInput: attrs }
|
45
|
+
end
|
46
|
+
|
47
|
+
def update(sobject, attrs)
|
48
|
+
id = attrs.fetch(attrs.keys.find { |k, v| k.to_s.casecmp?('id') }, nil)
|
49
|
+
raise ArgumentError, 'Id field missing from attrs.' unless id
|
50
|
+
|
51
|
+
attrs_without_id = attrs.reject { |k, v| k.to_s.casecmp?('id') }
|
52
|
+
requests << {
|
53
|
+
method: 'PATCH',
|
54
|
+
url: batch_api_path("#{sobject}/#{id}"),
|
55
|
+
richInput: attrs_without_id
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
def destroy(sobject, id)
|
60
|
+
requests << { method: 'DELETE', url: batch_api_path("#{sobject}/#{id}") }
|
61
|
+
end
|
62
|
+
|
63
|
+
def upsert(sobject, ext_field, attrs)
|
64
|
+
raise ArgumentError, 'External id field missing.' unless ext_field
|
65
|
+
|
66
|
+
ext_id = attrs.fetch(attrs.keys.find { |k, v|
|
67
|
+
k.to_s.casecmp?(ext_field.to_s)
|
68
|
+
}, nil)
|
69
|
+
raise ArgumentError, 'External id missing from attrs.' unless ext_id
|
70
|
+
|
71
|
+
attrs_without_ext_id = attrs.reject { |k, v| k.to_s.casecmp?(ext_field) }
|
72
|
+
requests << {
|
73
|
+
method: 'PATCH',
|
74
|
+
url: batch_api_path("#{sobject}/#{ext_field}/#{ext_id}"),
|
75
|
+
richInput: attrs_without_ext_id
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def batch_api_path(path)
|
82
|
+
"v#{options[:api_version]}/sobjects/#{path}"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -34,6 +34,7 @@ module Restforce
|
|
34
34
|
@field = field
|
35
35
|
@valid_for = options.delete(:valid_for)
|
36
36
|
raise "#{field} is not a dependent picklist" if @valid_for && !dependent?
|
37
|
+
|
37
38
|
replace(picklist_values)
|
38
39
|
end
|
39
40
|
|
@@ -82,7 +83,7 @@ module Restforce
|
|
82
83
|
# See http://www.salesforce.com/us/developer/docs/api/Content/sforce_api_calls_des
|
83
84
|
# cribesobjects_describesobjectresult.htm
|
84
85
|
def valid?(picklist_entry)
|
85
|
-
valid_for = picklist_entry['validFor'].ljust(16, 'A').
|
86
|
+
valid_for = picklist_entry['validFor'].ljust(16, 'A').unpack1('m').
|
86
87
|
unpack('C*')
|
87
88
|
(valid_for[index >> 3] & (0x80 >> index % 8)).positive?
|
88
89
|
end
|
@@ -5,12 +5,28 @@ module Restforce
|
|
5
5
|
module Streaming
|
6
6
|
# Public: Subscribe to a PushTopic
|
7
7
|
#
|
8
|
-
#
|
8
|
+
# topics - The name of the PushTopic channel(s) to subscribe to.
|
9
9
|
# block - A block to run when a new message is received.
|
10
10
|
#
|
11
11
|
# Returns a Faye::Subscription
|
12
|
-
def
|
13
|
-
|
12
|
+
def legacy_subscribe(topics, options = {}, &block)
|
13
|
+
topics = Array(topics).map { |channel| "/topic/#{channel}" }
|
14
|
+
subscription(topics, options, &block)
|
15
|
+
end
|
16
|
+
alias subscribe legacy_subscribe
|
17
|
+
|
18
|
+
# Public: Subscribe to one or more Streaming API channels
|
19
|
+
#
|
20
|
+
# channels - The name of the Streaming API (cometD) channel(s) to subscribe to.
|
21
|
+
# block - A block to run when a new message is received.
|
22
|
+
#
|
23
|
+
# Returns a Faye::Subscription
|
24
|
+
def subscription(channels, options = {}, &block)
|
25
|
+
one_or_more_channels = Array(channels)
|
26
|
+
one_or_more_channels.each do |channel|
|
27
|
+
replay_handlers[channel] = options[:replay]
|
28
|
+
end
|
29
|
+
faye.subscribe(one_or_more_channels, &block)
|
14
30
|
end
|
15
31
|
|
16
32
|
# Public: Faye client to use for subscribing to PushTopics
|
@@ -32,6 +48,62 @@ module Restforce
|
|
32
48
|
client.bind 'transport:up' do
|
33
49
|
Restforce.log "[COMETD UP]"
|
34
50
|
end
|
51
|
+
|
52
|
+
client.add_extension ReplayExtension.new(replay_handlers)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def replay_handlers
|
57
|
+
@_replay_handlers ||= {}
|
58
|
+
end
|
59
|
+
|
60
|
+
class ReplayExtension
|
61
|
+
def initialize(replay_handlers)
|
62
|
+
@replay_handlers = replay_handlers
|
63
|
+
end
|
64
|
+
|
65
|
+
def incoming(message, callback)
|
66
|
+
callback.call(message).tap do
|
67
|
+
channel = message.fetch('channel')
|
68
|
+
replay_id = message.fetch('data', {}).fetch('event', {})['replayId']
|
69
|
+
|
70
|
+
handler = @replay_handlers[channel]
|
71
|
+
if !replay_id.nil? && !handler.nil? && handler.respond_to?(:[]=)
|
72
|
+
# remember the last replay_id for this channel
|
73
|
+
handler[channel] = replay_id
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def outgoing(message, callback)
|
79
|
+
# Leave non-subscribe messages alone
|
80
|
+
return callback.call(message) unless message['channel'] == '/meta/subscribe'
|
81
|
+
|
82
|
+
channel = message['subscription']
|
83
|
+
|
84
|
+
# Set the replay value for the channel
|
85
|
+
message['ext'] ||= {}
|
86
|
+
message['ext']['replay'] = {
|
87
|
+
channel => replay_id(channel)
|
88
|
+
}
|
89
|
+
|
90
|
+
# Carry on and send the message to the server
|
91
|
+
callback.call message
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def replay_id(channel)
|
97
|
+
handler = @replay_handlers[channel]
|
98
|
+
if handler.is_a?(Integer)
|
99
|
+
handler # treat it as a scalar
|
100
|
+
elsif handler.respond_to?(:[])
|
101
|
+
# Ask for the latest replayId for this channel
|
102
|
+
handler[channel]
|
103
|
+
else
|
104
|
+
# Just pass it along
|
105
|
+
handler
|
106
|
+
end
|
35
107
|
end
|
36
108
|
end
|
37
109
|
end
|
data/lib/restforce/config.rb
CHANGED
@@ -34,6 +34,7 @@ module Restforce
|
|
34
34
|
|
35
35
|
def log(message)
|
36
36
|
return unless Restforce.log?
|
37
|
+
|
37
38
|
configuration.logger.send(configuration.log_level, message)
|
38
39
|
end
|
39
40
|
end
|
@@ -107,6 +108,9 @@ module Restforce
|
|
107
108
|
# The OAuth client secret
|
108
109
|
option :client_secret, default: lambda { ENV['SALESFORCE_CLIENT_SECRET'] }
|
109
110
|
|
111
|
+
# The private key for JWT authentication
|
112
|
+
option :jwt_key
|
113
|
+
|
110
114
|
# Set this to true if you're authenticating with a Sandbox instance.
|
111
115
|
# Defaults to false.
|
112
116
|
option :host, default: lambda { ENV['SALESFORCE_HOST'] || 'login.salesforce.com' }
|
data/lib/restforce/document.rb
CHANGED
@@ -6,8 +6,9 @@ module Restforce
|
|
6
6
|
# will attempt to either reauthenticate (username and password) or refresh
|
7
7
|
# the oauth access token (if a refresh token is present).
|
8
8
|
class Middleware::Authentication < Restforce::Middleware
|
9
|
-
autoload :Password,
|
10
|
-
autoload :Token,
|
9
|
+
autoload :Password, 'restforce/middleware/authentication/password'
|
10
|
+
autoload :Token, 'restforce/middleware/authentication/token'
|
11
|
+
autoload :JWTBearer, 'restforce/middleware/authentication/jwt_bearer'
|
11
12
|
|
12
13
|
# Rescue from 401's, authenticate then raise the error again so the client
|
13
14
|
# can reissue the request.
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jwt'
|
4
|
+
|
5
|
+
module Restforce
|
6
|
+
class Middleware
|
7
|
+
class Authentication
|
8
|
+
class JWTBearer < Restforce::Middleware::Authentication
|
9
|
+
def params
|
10
|
+
{
|
11
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
12
|
+
assertion: jwt_bearer_token
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def jwt_bearer_token
|
19
|
+
JWT.encode claim_set, private_key, 'RS256'
|
20
|
+
end
|
21
|
+
|
22
|
+
def claim_set
|
23
|
+
{
|
24
|
+
iss: @options[:client_id],
|
25
|
+
sub: @options[:username],
|
26
|
+
aud: @options[:host],
|
27
|
+
iat: Time.now.utc.to_i,
|
28
|
+
exp: Time.now.utc.to_i + 180
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
def private_key
|
33
|
+
OpenSSL::PKey::RSA.new(@options[:jwt_key])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|