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.
Files changed (37) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +9 -9
  3. data/.rubocop.yml +10 -12
  4. data/.rubocop_todo.yml +128 -81
  5. data/CHANGELOG.md +30 -1
  6. data/Gemfile +2 -1
  7. data/README.md +124 -12
  8. data/lib/restforce.rb +22 -1
  9. data/lib/restforce/abstract_client.rb +1 -0
  10. data/lib/restforce/attachment.rb +1 -0
  11. data/lib/restforce/concerns/api.rb +9 -6
  12. data/lib/restforce/concerns/authentication.rb +10 -0
  13. data/lib/restforce/concerns/base.rb +2 -0
  14. data/lib/restforce/concerns/batch_api.rb +87 -0
  15. data/lib/restforce/concerns/canvas.rb +1 -0
  16. data/lib/restforce/concerns/picklists.rb +2 -1
  17. data/lib/restforce/concerns/streaming.rb +75 -3
  18. data/lib/restforce/config.rb +4 -0
  19. data/lib/restforce/document.rb +1 -0
  20. data/lib/restforce/middleware/authentication.rb +3 -2
  21. data/lib/restforce/middleware/authentication/jwt_bearer.rb +38 -0
  22. data/lib/restforce/middleware/multipart.rb +1 -0
  23. data/lib/restforce/middleware/raise_error.rb +24 -8
  24. data/lib/restforce/signed_request.rb +1 -0
  25. data/lib/restforce/sobject.rb +1 -0
  26. data/lib/restforce/tooling/client.rb +3 -3
  27. data/lib/restforce/version.rb +1 -1
  28. data/restforce.gemspec +8 -7
  29. data/spec/fixtures/test_private.key +27 -0
  30. data/spec/integration/abstract_client_spec.rb +42 -1
  31. data/spec/support/fixture_helpers.rb +2 -2
  32. data/spec/unit/concerns/authentication_spec.rb +35 -0
  33. data/spec/unit/concerns/batch_api_spec.rb +107 -0
  34. data/spec/unit/concerns/streaming_spec.rb +144 -4
  35. data/spec/unit/middleware/authentication/jwt_bearer_spec.rb +62 -0
  36. data/spec/unit/middleware/raise_error_spec.rb +32 -11
  37. metadata +53 -32
@@ -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.send :include, Restforce::CoreExtensions unless Object.respond_to? :tap
98
+ Object.include Restforce::CoreExtensions unless Object.respond_to? :tap
78
99
  end
79
100
 
80
101
  if ENV['PROXY_URI']
@@ -7,5 +7,6 @@ module Restforce
7
7
  include Restforce::Concerns::Authentication
8
8
  include Restforce::Concerns::Caching
9
9
  include Restforce::Concerns::API
10
+ include Restforce::Concerns::BatchAPI
10
11
  end
11
12
  end
@@ -17,6 +17,7 @@ module Restforce
17
17
 
18
18
  def ensure_body
19
19
  return true if self.Body?
20
+
20
21
  raise 'You need to query the Body for the record first.'
21
22
  end
22
23
  end
@@ -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}/#{CGI.escape(external_id)}", attrs
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}/#{CGI.escape(id)}"
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}/#{CGI.escape(id)}"
434
+ "sobjects/#{sobject}/#{field}/#{ERB::Util.url_encode(id)}"
432
435
  else
433
- "sobjects/#{sobject}/#{CGI.escape(id)}"
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}/#{CGI.escape(id)}"
452
+ "sobjects/#{sobject}/#{field}/#{ERB::Util.url_encode(id)}"
450
453
  else
451
- "sobjects/#{sobject}/#{CGI.escape(id)}"
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
@@ -5,6 +5,7 @@ module Restforce
5
5
  module Canvas
6
6
  def decode_signed_request(signed_request)
7
7
  raise 'client_secret not set.' unless options[:client_secret]
8
+
8
9
  SignedRequest.decode(signed_request, options[:client_secret])
9
10
  end
10
11
  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').unpack('m').first.
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
- # channels - The name of the PushTopic channel(s) to subscribe to.
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 subscribe(channels, &block)
13
- faye.subscribe Array(channels).map { |channel| "/topic/#{channel}" }, &block
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
@@ -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' }
@@ -17,6 +17,7 @@ module Restforce
17
17
 
18
18
  def ensure_body
19
19
  return true if self.Body?
20
+
20
21
  raise 'You need to query the Body for the record first.'
21
22
  end
22
23
  end
@@ -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, 'restforce/middleware/authentication/password'
10
- autoload :Token, 'restforce/middleware/authentication/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
@@ -49,6 +49,7 @@ module Restforce
49
49
  # Files
50
50
  params.each do |k, v|
51
51
  next unless v.respond_to? :content_type
52
+
52
53
  parts << Faraday::Parts::Part.new(boundary,
53
54
  k.to_s,
54
55
  v)