restforce 3.0.1 → 5.1.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.
Files changed (72) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +9 -9
  3. data/.github/ISSUE_TEMPLATE/unhandled-salesforce-error.md +17 -0
  4. data/.github/dependabot.yml +19 -0
  5. data/.rubocop.yml +13 -14
  6. data/.rubocop_todo.yml +128 -81
  7. data/CHANGELOG.md +107 -1
  8. data/CONTRIBUTING.md +21 -1
  9. data/Dockerfile +31 -0
  10. data/Gemfile +10 -6
  11. data/README.md +168 -31
  12. data/UPGRADING.md +38 -0
  13. data/docker-compose.yml +7 -0
  14. data/lib/restforce/abstract_client.rb +1 -0
  15. data/lib/restforce/attachment.rb +1 -0
  16. data/lib/restforce/collection.rb +7 -2
  17. data/lib/restforce/concerns/api.rb +10 -7
  18. data/lib/restforce/concerns/authentication.rb +10 -0
  19. data/lib/restforce/concerns/base.rb +4 -2
  20. data/lib/restforce/concerns/batch_api.rb +87 -0
  21. data/lib/restforce/concerns/caching.rb +7 -0
  22. data/lib/restforce/concerns/canvas.rb +1 -0
  23. data/lib/restforce/concerns/connection.rb +3 -3
  24. data/lib/restforce/concerns/picklists.rb +4 -3
  25. data/lib/restforce/concerns/streaming.rb +73 -3
  26. data/lib/restforce/config.rb +8 -1
  27. data/lib/restforce/document.rb +1 -0
  28. data/lib/restforce/error_code.rb +638 -0
  29. data/lib/restforce/file_part.rb +24 -0
  30. data/lib/restforce/mash.rb +8 -3
  31. data/lib/restforce/middleware/authentication/jwt_bearer.rb +38 -0
  32. data/lib/restforce/middleware/authentication.rb +7 -3
  33. data/lib/restforce/middleware/caching.rb +1 -1
  34. data/lib/restforce/middleware/instance_url.rb +1 -1
  35. data/lib/restforce/middleware/logger.rb +8 -7
  36. data/lib/restforce/middleware/multipart.rb +1 -0
  37. data/lib/restforce/middleware/raise_error.rb +24 -9
  38. data/lib/restforce/middleware.rb +2 -0
  39. data/lib/restforce/signed_request.rb +1 -0
  40. data/lib/restforce/sobject.rb +1 -0
  41. data/lib/restforce/tooling/client.rb +3 -3
  42. data/lib/restforce/version.rb +1 -1
  43. data/lib/restforce.rb +21 -3
  44. data/restforce.gemspec +11 -20
  45. data/spec/fixtures/test_private.key +27 -0
  46. data/spec/integration/abstract_client_spec.rb +83 -33
  47. data/spec/integration/data/client_spec.rb +6 -2
  48. data/spec/spec_helper.rb +24 -1
  49. data/spec/support/client_integration.rb +7 -7
  50. data/spec/support/concerns.rb +1 -1
  51. data/spec/support/fixture_helpers.rb +3 -5
  52. data/spec/support/middleware.rb +1 -2
  53. data/spec/unit/collection_spec.rb +20 -2
  54. data/spec/unit/concerns/api_spec.rb +12 -12
  55. data/spec/unit/concerns/authentication_spec.rb +39 -4
  56. data/spec/unit/concerns/batch_api_spec.rb +107 -0
  57. data/spec/unit/concerns/caching_spec.rb +26 -0
  58. data/spec/unit/concerns/connection_spec.rb +2 -2
  59. data/spec/unit/concerns/streaming_spec.rb +144 -4
  60. data/spec/unit/config_spec.rb +1 -1
  61. data/spec/unit/error_code_spec.rb +61 -0
  62. data/spec/unit/mash_spec.rb +5 -0
  63. data/spec/unit/middleware/authentication/jwt_bearer_spec.rb +62 -0
  64. data/spec/unit/middleware/authentication/password_spec.rb +2 -2
  65. data/spec/unit/middleware/authentication/token_spec.rb +2 -2
  66. data/spec/unit/middleware/authentication_spec.rb +31 -4
  67. data/spec/unit/middleware/gzip_spec.rb +2 -2
  68. data/spec/unit/middleware/raise_error_spec.rb +57 -17
  69. data/spec/unit/signed_request_spec.rb +1 -1
  70. data/spec/unit/sobject_spec.rb +2 -5
  71. metadata +39 -108
  72. data/lib/restforce/upload_io.rb +0 -9
data/README.md CHANGED
@@ -8,7 +8,7 @@ Restforce is a ruby gem for the [Salesforce REST api](http://www.salesforce.com/
8
8
  Features include:
9
9
 
10
10
  * A clean and modular architecture using [Faraday middleware](https://github.com/technoweenie/faraday) and [Hashie::Mash](https://github.com/intridea/hashie/tree/v1.2.0)'d responses.
11
- * Support for interacting with multiple users from different orgs.
11
+ * Support for interacting with multiple users from different organizations.
12
12
  * Support for parent-to-child relationships.
13
13
  * Support for aggregate queries.
14
14
  * Support for the [Streaming API](#streaming)
@@ -19,13 +19,13 @@ Features include:
19
19
  * Support for dependent picklists.
20
20
  * Support for decoding [Force.com Canvas](http://www.salesforce.com/us/developer/docs/platform_connectpre/canvas_framework.pdf) signed requests. (NEW!)
21
21
 
22
- [Official Website](http://restforce.org/) | [Documentation](http://rubydoc.info/gems/restforce/frames) | [Changelog](https://github.com/restforce/restforce/tree/master/CHANGELOG.md)
22
+ [Official Website](https://restforce.github.io/) | [Documentation](http://rubydoc.info/gems/restforce/frames) | [Changelog](https://github.com/restforce/restforce/tree/master/CHANGELOG.md)
23
23
 
24
24
  ## Installation
25
25
 
26
26
  Add this line to your application's Gemfile:
27
27
 
28
- gem 'restforce', '~> 3.0.1'
28
+ gem 'restforce', '~> 5.1.1'
29
29
 
30
30
  And then execute:
31
31
 
@@ -35,7 +35,13 @@ Or install it yourself as:
35
35
 
36
36
  $ gem install restforce
37
37
 
38
- __As of [version 3.0.0](https://github.com/restforce/restforce/blob/master/CHANGELOG.md#300-aug-2-2018), this gem is only compatible with Ruby 2.3.0 and later.__ You'll need to use version 2.5.3 or earlier if you're running on Ruby 2.2, 2.1 or 2.0. For Ruby 1.9.3, you'll need to manually specify that you wish to use version 2.4.2.
38
+ __As of version 5.1.0, this gem is only compatible with Ruby 2.6.0 and later.__ If you're using an earlier Ruby version:
39
+
40
+ * for Ruby 2.5, use version 5.0.6 or earlier
41
+ * for Ruby 2.4, use version 4.3.0 or earlier
42
+ * for Ruby 2.3, use version 3.2.0 or earlier
43
+ * for Ruby versions 2.2, 2.1 and 2.0, use version 2.5.3 or earlier
44
+ * for Ruby 1.9.3, use version 2.4.2
39
45
 
40
46
  This gem is versioned using [Semantic Versioning](http://semver.org/), so you can be confident when updating that there will not be breaking changes outside of a major version (following format MAJOR.MINOR.PATCH, so for instance moving from 3.1.0 to 4.0.0 would be allowed to include incompatible API changes). See the [changelog](https://github.com/restforce/restforce/tree/master/CHANGELOG.md) for details on what has changed in each version.
41
47
 
@@ -43,12 +49,12 @@ This gem is versioned using [Semantic Versioning](http://semver.org/), so you ca
43
49
 
44
50
  Restforce is designed with flexibility and ease of use in mind. By default, all API calls will
45
51
  return [Hashie::Mash](https://github.com/intridea/hashie/tree/v1.2.0) objects,
46
- so you can do things like `client.query('select Id, (select Name from Children__r) from Account').Children__r.first.Name`.
52
+ so you can do things like `client.query('select Id, (select Name from Children__r) from Account').first.Children__r.first.Name`.
47
53
 
48
54
  ### Initialization
49
55
 
50
56
  Which authentication method you use really depends on your use case. If you're
51
- building an application where many users from different orgs are authenticated
57
+ building an application where many users from different organizations are authenticated
52
58
  through oauth and you need to interact with data in their org on their behalf,
53
59
  you should use the OAuth token authentication method.
54
60
 
@@ -78,7 +84,7 @@ client = Restforce.new(oauth_token: 'access_token',
78
84
  api_version: '41.0')
79
85
  ```
80
86
 
81
- The middleware will use the `refresh_token` automatically to acquire a new `access_token` if the existing `access_token` is invalid.
87
+ The middleware will use the `refresh_token` automatically to acquire a new `access_token` if the existing `access_token` is invalid. The refresh process uses the `host` option so make sure that is set correctly for sandbox organizations.
82
88
 
83
89
  `authentication_callback` is a proc that handles the response from Salesforce when the `refresh_token` is used to obtain a new `access_token`. This allows the `access_token` to be saved for re-use later - otherwise subsequent API calls will continue the cycle of "auth failure/issue new access_token/auth success".
84
90
 
@@ -111,6 +117,21 @@ client = Restforce.new(username: 'foo',
111
117
  api_version: '41.0')
112
118
  ```
113
119
 
120
+ #### JWT Bearer Token
121
+
122
+ If you prefer to use a [JWT Bearer Token](https://developer.salesforce.com/page/Digging_Deeper_into_OAuth_2.0_on_Force.com#Obtaining_an_Access_Token_using_a_JWT_Bearer_Token) to authenticate:
123
+
124
+ ```ruby
125
+ client = Restforce.new(username: 'foo',
126
+ client_id: 'client_id',
127
+ instance_url: 'instance_url',
128
+ jwt_key: 'certificate_private_key',
129
+ api_version: '38.0')
130
+ ```
131
+
132
+ The `jwt_key` option is the private key of the certificate uploaded to your Connected App in Salesforce.
133
+ Choose "use digital signatures" in the Connected App configuration screen to upload your certificate.
134
+
114
135
  You can also set the username, password, security token, client ID, client
115
136
  secret and API version in environment variables:
116
137
 
@@ -127,7 +148,17 @@ export SALESFORCE_API_VERSION="41.0"
127
148
  client = Restforce.new
128
149
  ```
129
150
 
130
- ### Proxy Support
151
+ #### Sandbox Organizations
152
+
153
+ You can connect to sandbox organizations by specifying a host. The default host is
154
+ 'login.salesforce.com':
155
+
156
+ ```ruby
157
+ client = Restforce.new(host: 'test.salesforce.com')
158
+ ```
159
+ The host can also be set with the environment variable `SALESFORCE_HOST`.
160
+
161
+ #### Proxy Support
131
162
 
132
163
  You can specify a HTTP proxy using the `proxy_uri` option, as follows, or by setting the `SALESFORCE_PROXY_URI` environment variable:
133
164
 
@@ -143,16 +174,6 @@ client = Restforce.new(username: 'foo',
143
174
 
144
175
  You may specify a username and password for the proxy with a URL along the lines of 'http://user:password@proxy.example.com:123'.
145
176
 
146
- #### Sandbox Orgs
147
-
148
- You can connect to sandbox orgs by specifying a host. The default host is
149
- 'login.salesforce.com':
150
-
151
- ```ruby
152
- client = Restforce.new(host: 'test.salesforce.com')
153
- ```
154
- The host can also be set with the environment variable `SALESFORCE_HOST`.
155
-
156
177
  #### Global configuration
157
178
 
158
179
  You can set any of the options passed into `Restforce.new` globally:
@@ -313,8 +334,11 @@ client.update('Account', Id: '0016000000MRatd', Name: 'Whizbang Corp')
313
334
  ```ruby
314
335
  # Update the record with external `External__c` external ID set to '12'
315
336
  client.upsert('Account', 'External__c', External__c: 12, Name: 'Foobar')
337
+ # => true or "RecordId"
316
338
  ```
317
339
 
340
+ The upsert method will return the record Id if included in the response body from the Salesforce API; otherwise, it returns true. Currently the Salesforce API only returns the Id for newly created records.
341
+
318
342
  ### destroy
319
343
 
320
344
  ```ruby
@@ -436,13 +460,13 @@ info.user_id
436
460
 
437
461
  ### File Uploads
438
462
 
439
- Using the new [Blob Data](http://www.salesforce.com/us/developer/docs/api_rest/Content/dome_sobject_insert_update_blob.htm) api feature (500mb limit):
463
+ Using the new [Blob Data](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_sobject_insert_update_blob.htm) api feature (500mb limit):
440
464
 
441
465
  ```ruby
442
466
  client.create('Document', FolderId: '00lE0000000FJ6H',
443
467
  Description: 'Document test',
444
468
  Name: 'My image',
445
- Body: Restforce::UploadIO.new(File.expand_path('image.jpg', __FILE__), 'image/jpeg')
469
+ Body: Restforce::FilePart.new(File.expand_path('image.jpg', __FILE__), 'image/jpeg')
446
470
  ```
447
471
 
448
472
  Using base64 encoded data (37.5mb limit):
@@ -454,7 +478,7 @@ client.create('Document', FolderId: '00lE0000000FJ6H',
454
478
  Body: Base64::encode64(File.read('image.jpg'))
455
479
  ```
456
480
 
457
- _See also: [Inserting or updating blob data](http://www.salesforce.com/us/developer/docs/api_rest/Content/dome_sobject_insert_update_blob.htm)_
481
+ _See also: [Inserting or updating blob data](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_sobject_insert_update_blob.htm)_
458
482
 
459
483
  * * *
460
484
 
@@ -473,11 +497,11 @@ document = client.query('select Id, Name, Body from Document').first
473
497
  File.open(document.Name, 'wb') { |f| f.write(document.Body) }
474
498
  ```
475
499
 
476
- **Note:** The example above is only applicable if your SOQL query returns a single Document record. If more than one record is returned,
500
+ **Note:** The example above is only applicable if your SOQL query returns a single Document record. If more than one record is returned,
477
501
  the Body field contains an URL to retrieve the BLOB content for the first 2000 records returned. Subsequent records contain the BLOB content
478
- in the Body field. This is confusing and hard to debug. See notes in [Issue #301](https://github.com/restforce/restforce/issues/301#issuecomment-298972959) explaining this detail.
479
- **Executive Summary:** Don't retrieve the Body field in a SOQL query; instead, use the BLOB retrieval URL documented
480
- in [SObject BLOB Retrieve](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_blob_retrieve.htm)
502
+ in the Body field. This is confusing and hard to debug. See notes in [Issue #301](https://github.com/restforce/restforce/issues/301#issuecomment-298972959) explaining this detail.
503
+ **Executive Summary:** Don't retrieve the Body field in a SOQL query; instead, use the BLOB retrieval URL documented
504
+ in [SObject BLOB Retrieve](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_blob_retrieve.htm)
481
505
 
482
506
  * * *
483
507
 
@@ -513,8 +537,10 @@ client.get('/services/apexrest/FieldCase', company: 'GenePoint')
513
537
 
514
538
  ### Streaming
515
539
 
516
- Restforce supports the [Streaming API](http://wiki.developerforce.com/page/Getting_Started_with_the_Force.com_Streaming_API), and makes implementing
517
- pub/sub with Salesforce a trivial task:
540
+ Restforce supports the [Streaming API](https://trailhead.salesforce.com/en/content/learn/modules/api_basics/api_basics_streaming), and makes implementing
541
+ pub/sub with Salesforce a trivial task.
542
+
543
+ Here is an example of creating and subscribing to a `PushTopic`:
518
544
 
519
545
  ```ruby
520
546
  # Restforce uses faye as the underlying implementation for CometD.
@@ -523,7 +549,7 @@ require 'faye'
523
549
  # Initialize a client with your username/password/oauth token/etc.
524
550
  client = Restforce.new(username: 'foo',
525
551
  password: 'bar',
526
- security_token: 'security token'
552
+ security_token: 'security token',
527
553
  client_id: 'client_id',
528
554
  client_secret: 'client_secret')
529
555
 
@@ -538,7 +564,7 @@ client.create!('PushTopic',
538
564
 
539
565
  EM.run do
540
566
  # Subscribe to the PushTopic.
541
- client.subscribe 'AllAccounts' do |message|
567
+ client.subscription '/topic/AllAccounts' do |message|
542
568
  puts message.inspect
543
569
  end
544
570
  end
@@ -547,7 +573,102 @@ end
547
573
  Boom, you're now receiving push notifications when Accounts are
548
574
  created/updated.
549
575
 
550
- _See also: [Force.com Streaming API docs](http://www.salesforce.com/us/developer/docs/api_streaming/index.htm)_
576
+ #### Replaying Events
577
+
578
+ Since API version 37.0, Salesforce stores events for 24 hours and they can be
579
+ replayed if your application experienced some downtime.
580
+
581
+ In order to replay past events, all you need to do is specify the last known
582
+ event ID when subscribing and you will receive all events that happened since
583
+ that event ID:
584
+
585
+ ```ruby
586
+ EM.run {
587
+ # Subscribe to the PushTopic.
588
+ client.subscription '/topic/AllAccounts', replay: 10 do |message|
589
+ puts message.inspect
590
+ end
591
+ }
592
+ ```
593
+
594
+ In this specific case you will see events with replay ID 11, 12 and so on.
595
+
596
+ There are two magic values for the replay ID accepted by Salesforce:
597
+
598
+ * `-2`, for getting all the events that appeared in the last 24 hours
599
+ * `-1`, for getting only newer events
600
+
601
+ **Warning**: Only use a replay ID of a event from the last 24 hours otherwise
602
+ Salesforce will not send anything, including newer events. If in doubt, use one
603
+ of the two magic replay IDs mentioned above.
604
+
605
+ You might want to store the replay ID in some sort of datastore so you can
606
+ access it, for example between application restarts. In that case, there is the
607
+ option of passing a custom replay handler which responds to `[]` and `[]=`.
608
+
609
+ Below is a sample replay handler that stores the replay ID for each channel in
610
+ memory using a Hash, stores a timestamp and has some rudimentary logic that
611
+ will use one of the magic IDs depending on the value of the timestamp:
612
+
613
+ ```ruby
614
+ class SimpleReplayHandler
615
+
616
+ MAX_AGE = 86_400 # 24 hours
617
+
618
+ INIT_REPLAY_ID = -1
619
+ DEFAULT_REPLAY_ID = -2
620
+
621
+ def initialize
622
+ @channels = {}
623
+ @last_modified = nil
624
+ end
625
+
626
+ # This method is called during the initial subscribe phase
627
+ # in order to send the correct replay ID.
628
+ def [](channel)
629
+ if @last_modified.nil?
630
+ puts "[#{channel}] No timestamp defined, sending magic replay ID #{INIT_REPLAY_ID}"
631
+
632
+ INIT_REPLAY_ID
633
+ elsif old_replay_id?
634
+ puts "[#{channel}] Old timestamp, sending magic replay ID #{DEFAULT_REPLAY_ID}"
635
+
636
+ DEFAULT_REPLAY_ID
637
+ else
638
+ @channels[channel]
639
+ end
640
+ end
641
+
642
+ def []=(channel, replay_id)
643
+ puts "[#{channel}] Writing replay ID: #{replay_id}"
644
+
645
+ @last_modified = Time.now
646
+ @channels[channel] = replay_id
647
+ end
648
+
649
+ def old_replay_id?
650
+ @last_modified.is_a?(Time) && Time.now - @last_modified > MAX_AGE
651
+ end
652
+ end
653
+ ```
654
+
655
+ In order to use it, simply pass the object as the value of the `replay` option
656
+ of the subscription:
657
+
658
+ ```ruby
659
+ EM.run {
660
+ # Subscribe to the PushTopic and use the custom replay handler to store any
661
+ # received replay ID.
662
+ client.subscription '/topic/AllAccounts', replay: SimpleReplayHandler.new do |message|
663
+ puts message.inspect
664
+ end
665
+ }
666
+ ```
667
+
668
+ _See also_:
669
+
670
+ * [Force.com Streaming API docs](http://www.salesforce.com/us/developer/docs/api_streaming/index.htm)
671
+ * [Message Durability docs](https://developer.salesforce.com/docs/atlas.en-us.api_streaming.meta/api_streaming/using_streaming_api_durability.htm)
551
672
 
552
673
  *Note:* Restforce's streaming implementation is known to be compatible with version `0.8.9` of the faye gem.
553
674
 
@@ -576,7 +697,23 @@ client.without_caching do
576
697
  end
577
698
  ```
578
699
 
579
- Caching is done on based on your authentication credentials, so cached responses will not be shared between different Salesforce logins.
700
+ If you prefer to opt in to caching on a per-request, you can do so by using .with_caching and
701
+ setting the `use_cache` config option to false:
702
+
703
+ ```ruby
704
+ Restforce.configure do |config|
705
+ config.cache = Rails.cache
706
+ config.use_cache = false
707
+ end
708
+ ```
709
+
710
+ ```ruby
711
+ client.with_caching do
712
+ client.query('select Id from Account')
713
+ end
714
+ ```
715
+
716
+ Caching is done based on your authentication credentials, so cached responses will not be shared between different Salesforce logins.
580
717
 
581
718
  * * *
582
719
 
data/UPGRADING.md ADDED
@@ -0,0 +1,38 @@
1
+ # Upgrading from Restforce 4.x to 5.x
2
+
3
+ __There are three breaking changes introduced in Restforce 5.x__. In this guide, you'll learn about these changes and what you should check in your code to make sure that it will work with the latest version of the library.
4
+
5
+ ## Error classes are now defined up-front, rather than dynamically at runtime
6
+
7
+ __Likelyhood of impact__: Moderate
8
+
9
+ The Salesforce REST API can return a range of `errorCode`s representing different kinds of errors. To make these easy to
10
+ handle in your code, we want to turn these into individual, specific exception classes in the `Restforce::ErrorCode` namespace that inherit from `Restforce:: ResponseError`.
11
+
12
+ Up until now, these exception classes have been defined dynamically at runtime which has some disadvantages - see the [pull request](https://github.com/restforce/restforce/pull/551) for more details.
13
+
14
+ In this version, we switch to defining them up-front in the code based on a list in the Salesforce documentation. There is a risk that we might have missed some errors which should be defined. If any errors are missed, they will be added in patch versions (e.g. `5.0.1`).
15
+
16
+ If your application won't run because you are referring to an exception class that no longer exists, or you see warnings logged anywhere, please [create an issue](https://github.com/restforce/restforce/issues/new?template=unhandled-salesforce-error.md&title=Unhandled+Salesforce+error%3A+%3Cinsert+error+code+here%3E).
17
+
18
+ ## Ruby 2.4 is no longer supported
19
+
20
+ __Likelyhood of impact__: Moderate
21
+
22
+ As of [5th April 2020](https://www.ruby-lang.org/en/news/2020/04/05/support-of-ruby-2-4-has-ended/), Ruby 2.4 is no longer officially supported as an active version of the Ruby language. That means that it will not receive patches and security fixes.
23
+
24
+ Accordingly, we've dropped support for Ruby 2.4 and earlier in the Restforce library. It *may* be compatible, bu we don't guarantee this or enforce it with automated tests.
25
+
26
+ Before you update to Restforce 5.x, you'll need to switch to Ruby 2.5 or later. The current version of Ruby at the time of wriing is 2.7.
27
+
28
+ ## `Restforce::UnauthorizedError` no longer inherits from `Restforce::Error`
29
+
30
+ __Likelyhood of impact__: Low
31
+
32
+ Previously, the `Restforce::UnauthorizedError` returned when the library couldn't authenticate with the Salesforce API inherits from `Restforce::Error`. So, if you used `rescue Restforce::Error` in your code, you'd catch these exceptions.
33
+
34
+ We've now changed this exception class to inherit from `Faraday::ClientError` which allows the response body returned from the Salesforce API to be attached to the error.
35
+
36
+ If you refer to `Restforce::Error` anywhere in your code, you should check whether you also need to take into account `Restforce::UnauthorizedError`.
37
+
38
+ If you refer to `Faraday::ClientError` anywhere in your code, you should check that you want the case where Restforce can't authenticate to be included.
@@ -0,0 +1,7 @@
1
+ version: "3.6"
2
+ services:
3
+ restforce:
4
+ build: .
5
+ image: restforce:dev
6
+ volumes:
7
+ - .:/srv
@@ -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
@@ -12,12 +12,12 @@ module Restforce
12
12
  end
13
13
 
14
14
  # Yield each value on each page.
15
- def each
15
+ def each(&block)
16
16
  @raw_page['records'].each { |record| yield Restforce::Mash.build(record, @client) }
17
17
 
18
18
  np = next_page
19
19
  while np
20
- np.current_page.each { |record| yield record }
20
+ np.current_page.each(&block)
21
21
  np = np.next_page
22
22
  end
23
23
  end
@@ -33,6 +33,11 @@ module Restforce
33
33
  end
34
34
  alias length size
35
35
 
36
+ # Returns true if the size of the Collection is zero.
37
+ def empty?
38
+ size.zero?
39
+ end
40
+
36
41
  # Return array of the elements on the current page
37
42
  def current_page
38
43
  first(@raw_page['records'].size)
@@ -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?
@@ -507,7 +510,7 @@ module Restforce
507
510
 
508
511
  # Internal: Errors that should be rescued from in non-bang methods
509
512
  def exceptions
510
- [Faraday::Error::ClientError]
513
+ [Faraday::Error]
511
514
  end
512
515
  end
513
516
  end
@@ -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').
@@ -59,9 +61,9 @@ module Restforce
59
61
  def initialize(opts = {})
60
62
  raise ArgumentError, 'Please specify a hash of options' unless opts.is_a?(Hash)
61
63
 
62
- @options = Hash[Restforce.configuration.options.map do |option|
64
+ @options = Restforce.configuration.options.map do |option|
63
65
  [option, Restforce.configuration.send(option)]
64
- end]
66
+ end.to_h
65
67
 
66
68
  @options.merge! opts
67
69
  yield builder if block_given?
@@ -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
@@ -15,6 +15,13 @@ module Restforce
15
15
  options.delete(:use_cache)
16
16
  end
17
17
 
18
+ def with_caching
19
+ options[:use_cache] = true
20
+ yield
21
+ ensure
22
+ options[:use_cache] = false
23
+ end
24
+
18
25
  private
19
26
 
20
27
  # Internal: Cache to use for the caching middleware
@@ -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
@@ -72,9 +72,9 @@ module Restforce
72
72
  # Internal: Faraday Connection options
73
73
  def connection_options
74
74
  { request: {
75
- timeout: options[:timeout],
76
- open_timeout: options[:timeout]
77
- },
75
+ timeout: options[:timeout],
76
+ open_timeout: options[:timeout]
77
+ },
78
78
  proxy: options[:proxy_uri],
79
79
  ssl: options[:ssl] }
80
80
  end