restforce 3.0.0 → 3.2.1

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.
@@ -9,7 +9,8 @@ module Restforce
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)
12
+ def subscribe(channels, options = {}, &block)
13
+ Array(channels).each { |channel| replay_handlers[channel] = options[:replay] }
13
14
  faye.subscribe Array(channels).map { |channel| "/topic/#{channel}" }, &block
14
15
  end
15
16
 
@@ -32,6 +33,62 @@ module Restforce
32
33
  client.bind 'transport:up' do
33
34
  Restforce.log "[COMETD UP]"
34
35
  end
36
+
37
+ client.add_extension ReplayExtension.new(replay_handlers)
38
+ end
39
+ end
40
+
41
+ def replay_handlers
42
+ @_replay_handlers ||= {}
43
+ end
44
+
45
+ class ReplayExtension
46
+ def initialize(replay_handlers)
47
+ @replay_handlers = replay_handlers
48
+ end
49
+
50
+ def incoming(message, callback)
51
+ callback.call(message).tap do
52
+ channel = message.fetch('channel').gsub('/topic/', '')
53
+ replay_id = message.fetch('data', {}).fetch('event', {})['replayId']
54
+
55
+ handler = @replay_handlers[channel]
56
+ if !replay_id.nil? && !handler.nil? && handler.respond_to?(:[]=)
57
+ # remember the last replay_id for this channel
58
+ handler[channel] = replay_id
59
+ end
60
+ end
61
+ end
62
+
63
+ def outgoing(message, callback)
64
+ # Leave non-subscribe messages alone
65
+ return callback.call(message) unless message['channel'] == '/meta/subscribe'
66
+
67
+ channel = message['subscription'].gsub('/topic/', '')
68
+
69
+ # Set the replay value for the channel
70
+ message['ext'] ||= {}
71
+ message['ext']['replay'] = {
72
+ "/topic/#{channel}" => replay_id(channel)
73
+ }
74
+
75
+ # Carry on and send the message to the server
76
+ callback.call message
77
+ end
78
+
79
+ private
80
+
81
+ def replay_id(channel)
82
+ handler = @replay_handlers[channel]
83
+ if handler.is_a?(Integer)
84
+ handler # treat it as a scalar
85
+ elsif handler.respond_to?(:[])
86
+ # Ask for the latest replayId for this channel
87
+ handler[channel]
88
+ else
89
+ # Just pass it along
90
+ handler
91
+ end
35
92
  end
36
93
  end
37
94
  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
@@ -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
@@ -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)
@@ -6,23 +6,30 @@ module Restforce
6
6
  @env = env
7
7
  case env[:status]
8
8
  when 300
9
- raise Faraday::Error::ClientError.new("300: The external ID provided matches " \
10
- "more than one record",
11
- response_values)
9
+ raise Restforce::MatchesMultipleError.new(
10
+ "300: The external ID provided matches more than one record",
11
+ response_values
12
+ )
12
13
  when 401
13
14
  raise Restforce::UnauthorizedError, message
14
15
  when 404
15
- raise Faraday::Error::ResourceNotFound, message
16
+ raise Restforce::NotFoundError, message
16
17
  when 413
17
- raise Faraday::Error::ClientError.new("413: Request Entity Too Large",
18
- response_values)
18
+ raise Restforce::EntityTooLargeError.new(
19
+ "413: Request Entity Too Large",
20
+ response_values
21
+ )
19
22
  when 400...600
20
- raise Faraday::Error::ClientError.new(message, response_values)
23
+ klass = exception_class_for_error_code(body['errorCode'])
24
+ raise klass.new(message, response_values)
21
25
  end
22
26
  end
23
27
 
24
28
  def message
25
- "#{body['errorCode']}: #{body['message']}"
29
+ message = "#{body['errorCode']}: #{body['message']}"
30
+ message << "\nRESPONSE: #{JSON.dump(@env[:body])}"
31
+ rescue StandardError
32
+ message # if JSON.dump fails, return message without extra detail
26
33
  end
27
34
 
28
35
  def body
@@ -43,5 +50,14 @@ module Restforce
43
50
  body: @env[:body]
44
51
  }
45
52
  end
53
+
54
+ ERROR_CODE_MATCHER = /\A[A-Z_]+\z/.freeze
55
+
56
+ def exception_class_for_error_code(error_code)
57
+ return Restforce::ResponseError unless ERROR_CODE_MATCHER.match?(error_code)
58
+
59
+ constant_name = error_code.split('_').map(&:capitalize).join.to_sym
60
+ Restforce::ErrorCode.const_get(constant_name)
61
+ end
46
62
  end
47
63
  end
@@ -27,6 +27,7 @@ module Restforce
27
27
  # Returns nil if the signed request is invalid.
28
28
  def decode
29
29
  return nil if signature != hmac
30
+
30
31
  JSON.parse(Base64.decode64(payload))
31
32
  end
32
33
 
@@ -63,6 +63,7 @@ module Restforce
63
63
 
64
64
  def ensure_id
65
65
  return true if self.Id?
66
+
66
67
  raise ArgumentError, 'You need to query the Id for the record first.'
67
68
  end
68
69
  end
@@ -5,9 +5,9 @@ module Restforce
5
5
  class Client < AbstractClient
6
6
  private
7
7
 
8
- def api_path(path)
9
- super("tooling/#{path}")
10
- end
8
+ def api_path(path)
9
+ super("tooling/#{path}")
10
+ end
11
11
  end
12
12
  end
13
13
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Restforce
4
- VERSION = '3.0.0'
4
+ VERSION = '3.2.1'
5
5
  end
data/lib/restforce.rb CHANGED
@@ -29,6 +29,7 @@ module Restforce
29
29
  autoload :Verbs, 'restforce/concerns/verbs'
30
30
  autoload :Base, 'restforce/concerns/base'
31
31
  autoload :API, 'restforce/concerns/api'
32
+ autoload :BatchAPI, 'restforce/concerns/batch_api'
32
33
  end
33
34
 
34
35
  module Data
@@ -44,6 +45,25 @@ module Restforce
44
45
  AuthenticationError = Class.new(Error)
45
46
  UnauthorizedError = Class.new(Error)
46
47
  APIVersionError = Class.new(Error)
48
+ BatchAPIError = Class.new(Error)
49
+
50
+ # Inherit from Faraday::Error::ResourceNotFound for backwards-compatibility
51
+ # Consumers of this library that rescue and handle Faraday::Error::ResourceNotFound
52
+ # can continue to do so.
53
+ NotFoundError = Class.new(Faraday::Error::ResourceNotFound)
54
+
55
+ # Inherit from Faraday::Error::ClientError for backwards-compatibility
56
+ # Consumers of this library that rescue and handle Faraday::Error::ClientError
57
+ # can continue to do so.
58
+ ResponseError = Class.new(Faraday::Error::ClientError)
59
+ MatchesMultipleError= Class.new(ResponseError)
60
+ EntityTooLargeError = Class.new(ResponseError)
61
+
62
+ module ErrorCode
63
+ def self.const_missing(constant_name)
64
+ const_set constant_name, Class.new(ResponseError)
65
+ end
66
+ end
47
67
 
48
68
  class << self
49
69
  # Alias for Restforce::Data::Client.new
@@ -74,7 +94,7 @@ module Restforce
74
94
  self
75
95
  end
76
96
  end
77
- Object.send :include, Restforce::CoreExtensions unless Object.respond_to? :tap
97
+ Object.include Restforce::CoreExtensions unless Object.respond_to? :tap
78
98
  end
79
99
 
80
100
  if ENV['PROXY_URI']
data/restforce.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require File.expand_path('../lib/restforce/version', __FILE__)
3
+ require File.expand_path('lib/restforce/version', __dir__)
4
4
 
5
5
  Gem::Specification.new do |gem|
6
6
  gem.authors = ["Eric J. Holmes", "Tim Rogers"]
@@ -25,16 +25,16 @@ Gem::Specification.new do |gem|
25
25
  gem.required_ruby_version = '>= 2.3'
26
26
 
27
27
  gem.add_dependency 'faraday', '<= 1.0', '>= 0.9.0'
28
- gem.add_dependency 'faraday_middleware', ['>= 0.8.8', '<= 1.0']
28
+ gem.add_dependency 'faraday_middleware', ['>= 0.8.8', '< 1.0']
29
29
 
30
30
  gem.add_dependency 'json', '>= 1.7.5'
31
31
 
32
32
  gem.add_dependency 'hashie', ['>= 1.2.0', '< 4.0']
33
33
 
34
+ gem.add_development_dependency 'faye' unless RUBY_PLATFORM == 'java'
34
35
  gem.add_development_dependency 'rspec', '~> 2.14.0'
35
- gem.add_development_dependency 'webmock', '~> 3.4.0'
36
- gem.add_development_dependency 'simplecov', '~> 0.15.0'
37
- gem.add_development_dependency 'rubocop', '~> 0.50.0'
38
36
  gem.add_development_dependency 'rspec_junit_formatter', '~> 0.3.0'
39
- gem.add_development_dependency 'faye' unless RUBY_PLATFORM == 'java'
37
+ gem.add_development_dependency 'rubocop', '~> 0.75.0'
38
+ gem.add_development_dependency 'simplecov', '~> 0.17.1'
39
+ gem.add_development_dependency 'webmock', '~> 3.7.6'
40
40
  end
@@ -96,7 +96,7 @@ shared_examples_for Restforce::AbstractClient do
96
96
  subject do
97
97
  client.create('Account', Name: 'Foobar',
98
98
  Blob: Restforce::UploadIO.new(
99
- File.expand_path('../../fixtures/blob.jpg', __FILE__),
99
+ File.expand_path('../fixtures/blob.jpg', __dir__),
100
100
  'image/jpeg'
101
101
  ))
102
102
  end
@@ -209,6 +209,24 @@ shared_examples_for Restforce::AbstractClient do
209
209
  end
210
210
  end
211
211
  end
212
+
213
+ context 'when created with a space in the id' do
214
+ requests 'sobjects/Account/External__c/foo%20bar',
215
+ method: :patch,
216
+ with_body: "{\"Name\":\"Foobar\"}",
217
+ fixture: 'sobject/upsert_created_success_response'
218
+
219
+ [:External__c, 'External__c', :external__c, 'external__c'].each do |key|
220
+ context "with #{key.inspect} as the external id" do
221
+ subject do
222
+ client.upsert!('Account', 'External__c', key => 'foo bar',
223
+ :Name => 'Foobar')
224
+ end
225
+
226
+ it { should eq 'foo' }
227
+ end
228
+ end
229
+ end
212
230
  end
213
231
 
214
232
  describe '.destroy!' do
@@ -229,6 +247,13 @@ shared_examples_for Restforce::AbstractClient do
229
247
 
230
248
  it { should be_true }
231
249
  end
250
+
251
+ context 'with a space in the id' do
252
+ subject(:destroy!) { client.destroy!('Account', '001D000000 INjVe') }
253
+ requests 'sobjects/Account/001D000000%20INjVe', method: :delete
254
+
255
+ it { should be_true }
256
+ end
232
257
  end
233
258
 
234
259
  describe '.destroy' do
@@ -266,6 +291,14 @@ shared_examples_for Restforce::AbstractClient do
266
291
  subject { client.find('Account', '1234', 'External_Field__c') }
267
292
  it { should be_a Hash }
268
293
  end
294
+
295
+ context 'with a space in an external id' do
296
+ requests 'sobjects/Account/External_Field__c/12%2034',
297
+ fixture: 'sobject/sobject_find_success_response'
298
+
299
+ subject { client.find('Account', '12 34', 'External_Field__c') }
300
+ it { should be_a Hash }
301
+ end
269
302
  end
270
303
 
271
304
  describe '.select' do
@@ -284,6 +317,14 @@ shared_examples_for Restforce::AbstractClient do
284
317
  subject { client.select('Account', '1234', ['External_Field__c']) }
285
318
  it { should be_a Hash }
286
319
  end
320
+
321
+ context 'with a space in the id' do
322
+ requests 'sobjects/Account/12%2034',
323
+ fixture: 'sobject/sobject_select_success_response'
324
+
325
+ subject { client.select('Account', '12 34', nil, nil) }
326
+ it { should be_a Hash }
327
+ end
287
328
  end
288
329
 
289
330
  context 'when an external id is specified' do
@@ -27,8 +27,8 @@ module FixtureHelpers
27
27
  stub
28
28
  end
29
29
 
30
- def fixture(f)
31
- File.read(File.expand_path("../../fixtures/#{f}.json", __FILE__))
30
+ def fixture(filename)
31
+ File.read(File.expand_path("../../fixtures/#{filename}.json", __FILE__))
32
32
  end
33
33
  end
34
34
 
@@ -350,6 +350,16 @@ describe Restforce::Concerns::API do
350
350
  and_return(response)
351
351
  expect(result).to be_true
352
352
  end
353
+
354
+ context 'and the response body is a string' do
355
+ it 'returns true' do
356
+ response.stub(:body) { '' }
357
+ client.should_receive(:api_patch).
358
+ with('sobjects/Whizbang/External_ID__c/1234', {}).
359
+ and_return(response)
360
+ expect(result).to be_true
361
+ end
362
+ end
353
363
  end
354
364
 
355
365
  context 'when the record is found and created' do
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Restforce::Concerns::BatchAPI do
6
+ let(:endpoint) { 'composite/batch' }
7
+
8
+ before do
9
+ client.should_receive(:options).and_return(api_version: 34.0)
10
+ end
11
+
12
+ shared_examples_for 'batched requests' do
13
+ it '#create' do
14
+ client.
15
+ should_receive(:api_post).
16
+ with(endpoint, { batchRequests: [
17
+ { method: 'POST', url: 'v34.0/sobjects/Object', richInput: { name: 'test' } }
18
+ ], haltOnError: halt_on_error }.to_json).
19
+ and_return(response)
20
+
21
+ client.send(method) do |subrequests|
22
+ subrequests.create('Object', name: 'test')
23
+ end
24
+ end
25
+
26
+ it '#update' do
27
+ client.
28
+ should_receive(:api_post).
29
+ with(endpoint, { batchRequests: [
30
+ { method: 'PATCH', url: "v34.0/sobjects/Object/123", richInput: {
31
+ name: 'test'
32
+ } }
33
+ ], haltOnError: halt_on_error }.to_json).
34
+ and_return(response)
35
+
36
+ client.send(method) do |subrequests|
37
+ subrequests.update('Object', id: '123', name: 'test')
38
+ end
39
+ end
40
+
41
+ it '#destroy' do
42
+ client.
43
+ should_receive(:api_post).
44
+ with(endpoint, { batchRequests: [
45
+ { method: 'DELETE', url: "v34.0/sobjects/Object/123" }
46
+ ], haltOnError: halt_on_error }.to_json).
47
+ and_return(response)
48
+
49
+ client.send(method) do |subrequests|
50
+ subrequests.destroy('Object', '123')
51
+ end
52
+ end
53
+
54
+ it '#upsert' do
55
+ client.
56
+ should_receive(:api_post).
57
+ with(endpoint, { batchRequests: [
58
+ { method: 'PATCH', url: 'v34.0/sobjects/Object/extIdField__c/456', richInput: {
59
+ name: 'test'
60
+ } }
61
+ ], haltOnError: halt_on_error }.to_json).
62
+ and_return(response)
63
+
64
+ client.send(method) do |subrequests|
65
+ subrequests.upsert('Object', 'extIdField__c',
66
+ extIdField__c: '456', name: 'test')
67
+ end
68
+ end
69
+
70
+ it 'multiple subrequests' do
71
+ client.
72
+ should_receive(:api_post).
73
+ with(endpoint, { batchRequests: [
74
+ { method: 'POST', url: 'v34.0/sobjects/Object', richInput: {
75
+ name: 'test'
76
+ } },
77
+ { method: 'PATCH', url: "v34.0/sobjects/Object/123", richInput: {
78
+ name: 'test'
79
+ } },
80
+ { method: 'DELETE', url: "v34.0/sobjects/Object/123" }
81
+ ], haltOnError: halt_on_error }.to_json).
82
+ and_return(response)
83
+
84
+ client.send(method) do |subrequests|
85
+ subrequests.create('Object', name: 'test')
86
+ subrequests.update('Object', id: '123', name: 'test')
87
+ subrequests.destroy('Object', '123')
88
+ end
89
+ end
90
+ end
91
+
92
+ describe '#batch' do
93
+ let(:method) { :batch }
94
+ let(:halt_on_error) { false }
95
+ let(:response) { double('Faraday::Response', body: { 'results' => [] }) }
96
+ it_behaves_like 'batched requests'
97
+ end
98
+
99
+ describe '#batch!' do
100
+ let(:method) { :batch! }
101
+ let(:halt_on_error) { true }
102
+ let(:response) {
103
+ double('Faraday::Response', body: { 'hasErrors' => false, 'results' => [] })
104
+ }
105
+ it_behaves_like 'batched requests'
106
+ end
107
+ end
@@ -17,6 +17,33 @@ describe Restforce::Concerns::Streaming, event_machine: true do
17
17
 
18
18
  client.subscribe(channels, &subscribe_block)
19
19
  end
20
+
21
+ context "replay_handlers" do
22
+ before {
23
+ faye_double.should_receive(:subscribe).at_least(1)
24
+ client.stub faye: faye_double
25
+ }
26
+
27
+ it 'registers nil handlers when no replay option is given' do
28
+ client.subscribe(channels, &subscribe_block)
29
+ client.replay_handlers.should eq('channel1' => nil, 'channel2' => nil)
30
+ end
31
+
32
+ it 'registers a replay_handler for each channel given' do
33
+ client.subscribe(channels, replay: -2, &subscribe_block)
34
+ client.replay_handlers.should eq('channel1' => -2, 'channel2' => -2)
35
+ end
36
+
37
+ it 'replaces earlier handlers in subsequent calls' do
38
+ client.subscribe(%w[channel1 channel2], replay: 2, &subscribe_block)
39
+ client.subscribe(%w[channel2 channel3], replay: 3, &subscribe_block)
40
+ client.replay_handlers.should eq(
41
+ 'channel1' => 2,
42
+ 'channel2' => 3,
43
+ 'channel3' => 3
44
+ )
45
+ end
46
+ end
20
47
  end
21
48
 
22
49
  describe '.faye' do
@@ -42,6 +69,8 @@ describe Restforce::Concerns::Streaming, event_machine: true do
42
69
  faye_double.should_receive(:set_header).with('Authorization', 'OAuth secret2')
43
70
  faye_double.should_receive(:bind).with('transport:down').and_yield
44
71
  faye_double.should_receive(:bind).with('transport:up').and_yield
72
+ faye_double.should_receive(:add_extension).with \
73
+ kind_of(Restforce::Concerns::Streaming::ReplayExtension)
45
74
  subject
46
75
  end
47
76
  end
@@ -52,4 +81,87 @@ describe Restforce::Concerns::Streaming, event_machine: true do
52
81
  end
53
82
  end
54
83
  end
84
+
85
+ describe Restforce::Concerns::Streaming::ReplayExtension do
86
+ let(:handlers) { {} }
87
+ let(:extension) { Restforce::Concerns::Streaming::ReplayExtension.new(handlers) }
88
+
89
+ it 'sends nil without a specified handler' do
90
+ output = subscribe(extension, to: "channel1")
91
+ read_replay(output).should eq('/topic/channel1' => nil)
92
+ end
93
+
94
+ it 'with a scalar replay id' do
95
+ handlers['channel1'] = -2
96
+ output = subscribe(extension, to: "channel1")
97
+ read_replay(output).should eq('/topic/channel1' => -2)
98
+ end
99
+
100
+ it 'with a hash' do
101
+ hash_handler = { 'channel1' => -1, 'channel2' => -2 }
102
+
103
+ handlers['channel1'] = hash_handler
104
+ handlers['channel2'] = hash_handler
105
+
106
+ output = subscribe(extension, to: "channel1")
107
+ read_replay(output).should eq('/topic/channel1' => -1)
108
+
109
+ output = subscribe(extension, to: "channel2")
110
+ read_replay(output).should eq('/topic/channel2' => -2)
111
+ end
112
+
113
+ it 'with an object' do
114
+ custom_handler = double('custom_handler')
115
+ custom_handler.should_receive(:[]).and_return(123)
116
+ handlers['channel1'] = custom_handler
117
+
118
+ output = subscribe(extension, to: "channel1")
119
+ read_replay(output).should eq('/topic/channel1' => 123)
120
+ end
121
+
122
+ it 'remembers the last replayId' do
123
+ handler = { 'channel1' => 41 }
124
+ handlers['channel1'] = handler
125
+ message = {
126
+ 'channel' => '/topic/channel1',
127
+ 'data' => {
128
+ 'event' => { 'replayId' => 42 }
129
+ }
130
+ }
131
+
132
+ extension.incoming(message, ->(m) {})
133
+ handler.should eq('channel1' => 42)
134
+ end
135
+
136
+ it 'when an incoming message has no replayId' do
137
+ handler = { 'channel1' => 41 }
138
+ handlers['channel1'] = handler
139
+
140
+ message = {
141
+ 'channel' => '/topic/channel1',
142
+ 'data' => {}
143
+ }
144
+
145
+ extension.incoming(message, ->(m) {})
146
+ handler.should eq('channel1' => 41)
147
+ end
148
+
149
+ private
150
+
151
+ def subscribe(extension, options = {})
152
+ output = nil
153
+ message = {
154
+ 'channel' => '/meta/subscribe',
155
+ 'subscription' => "/topic/#{options[:to]}"
156
+ }
157
+ extension.outgoing(message, ->(m) {
158
+ output = m
159
+ })
160
+ output
161
+ end
162
+
163
+ def read_replay(message)
164
+ message.fetch('ext', {})['replay']
165
+ end
166
+ end
55
167
  end