gocardless_pro 2.10.0 → 2.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06d8895916dd0f220182f39eb35a9c286f50288c84391984715f2362b142319d
4
- data.tar.gz: d2839e1c60c82d2907980843abb9c8e1e6a911d7a157752be15cf773b8333b4d
3
+ metadata.gz: e050e363d51abaf3c39a5f492efa1512145e38fd83b6ca89271c5a36912d58fb
4
+ data.tar.gz: 5570e9de8ac349af06b99a282246e69ff4df075826414f5bf9f2df06b65a52f2
5
5
  SHA512:
6
- metadata.gz: 4cb9c53de215e5ad3c01d83f0568e3b6fa58bd5fc50daa43de0115977cf49e0d74f9e47836d99d1c5c0b9eb51663b778c4de572c7a8190cb97650b1d6cfbfa62
7
- data.tar.gz: e247fb19810b43ca4131471833e700734c77231f0ebb8109da023229846c63cb1a12df04d232d2430ad69ec46fc9ce6e51ed4dc7a09d9d9066467125c72a8408
6
+ metadata.gz: 7187e0274d9cf84dec3562521da846c90fddd75316eecc4fbb69b4b62a124a21096fa1fc929ba39ac42bbe83e8e1aa8918b78dfa31ee69dacb7f4fc3812d416a
7
+ data.tar.gz: f72fb680bd59ac97db9da8341e46d87bc71be4b65a802d51f5eecb443d075d98e64327c1a2602497c4848074e7997636864523643d6b965ff33fd00bb7b60091
data/README.md CHANGED
@@ -173,6 +173,60 @@ If the client is unable to connect to GoCardless, an appropriate exception will
173
173
 
174
174
  If an error occurs which is likely to be resolved with a retry (e.g. a timeout or connection error), and the request being made is idempotent, the library will automatically retry the request twice (i.e. it will make up to 3 attempts) before giving up and raising an exception.
175
175
 
176
+ ### Handling webhooks
177
+
178
+ GoCardless supports webhooks, allowing you to receive real-time notifications when things happen in your account, so you can take automatic actions in response, for example:
179
+
180
+ * When a customer cancels their mandate with the bank, suspend their club membership
181
+ * When a payment fails due to lack of funds, mark their invoice as unpaid
182
+ * When a customer’s subscription generates a new payment, log it in their “past payments” list
183
+
184
+ The client allows you to validate that a webhook you receive is genuinely from GoCardless, and to parse it into `GoCardlessPro::Resources::Event` objects which are easy to work with:
185
+
186
+ ```ruby
187
+ class WebhooksController < ApplicationController
188
+ include ActionController::Live
189
+
190
+ protect_from_forgery except: :create
191
+
192
+ def create
193
+ # When you create a webhook endpoint, you can specify a secret. When GoCardless sends
194
+ # you a webhook, it'll sign the body using that secret. Since only you and GoCardless
195
+ # know the secret, you can check the signature and ensure that the webhook is truly
196
+ # from GoCardless.
197
+ #
198
+ # We recommend storing your webhook endpoint secret in an environment variable
199
+ # for security, but you could include it as a string directly in your code
200
+ webhook_endpoint_secret = ENV['GOCARDLESS_WEBHOOK_ENDPOINT_SECRET']
201
+
202
+ begin
203
+ # This example is for Rails. In a Rack app (e.g. Sinatra), access the POST body with
204
+ # `request.body.tap(&:rewind).read` and the Webhook-Signature header with
205
+ # `request.env['HTTP_WEBHOOK_SIGNATURE']`.
206
+ events = GoCardlessPro::Webhook.parse(
207
+ request_body: request.raw_post,
208
+ signature_header: request.headers['Webhook-Signature'],
209
+ webhook_endpoint_secret: webhook_endpoint_secret
210
+ )
211
+
212
+ events.each do |event|
213
+ # You can access each event in the webhook.
214
+ puts event.id
215
+ end
216
+
217
+ render status: 200, nothing: true
218
+ rescue GoCardlessPro::Webhook::InvalidSignatureError
219
+ # The webhook doesn't appear to be genuinely from GoCardless, as the signature
220
+ # included in the `Webhook-Signature` header doesn't match one computed with your
221
+ # webhook endpoint secret and the body
222
+ render status: 498, nothing: true
223
+ end
224
+ end
225
+ end
226
+ ```
227
+
228
+ For more details on working with webhooks, see our ["Getting started" guide](https://developer.gocardless.com/getting-started/api/introduction/?lang=ruby).
229
+
176
230
  ### Using the OAuth API
177
231
 
178
232
  The API includes [OAuth](https://developer.gocardless.com/pro/2015-07-06/#guides-oauth) functionality, which allows you to work with other users' GoCardless accounts. Once a user approves you, you can use the GoCardless API on their behalf and receive their webhooks.
@@ -36,6 +36,7 @@ require_relative 'gocardless_pro/paginator'
36
36
  require_relative 'gocardless_pro/request'
37
37
  require_relative 'gocardless_pro/response'
38
38
  require_relative 'gocardless_pro/api_response'
39
+ require_relative 'gocardless_pro/webhook'
39
40
 
40
41
  require_relative 'gocardless_pro/resources/bank_details_lookup'
41
42
  require_relative 'gocardless_pro/services/bank_details_lookups_service'
@@ -133,7 +133,7 @@ module GoCardlessPro
133
133
  'User-Agent' => user_agent.to_s,
134
134
  'Content-Type' => 'application/json',
135
135
  'GoCardless-Client-Library' => 'gocardless-pro-ruby',
136
- 'GoCardless-Client-Version' => '2.10.0',
136
+ 'GoCardless-Client-Version' => '2.11.0',
137
137
  },
138
138
  }
139
139
  end
@@ -21,6 +21,10 @@ module GoCardlessPro
21
21
  # account. You may wish to handle this by updating the existing record
22
22
  # instead, the ID of which will be provided as
23
23
  # `links[creditor_bank_account]` in the error response.
24
+ #
25
+ # <p class="restricted-notice"><strong>Restricted</strong>: This API is not
26
+ # available for
27
+ # partner integrations.</p>
24
28
  class CreditorBankAccount
25
29
  attr_reader :account_holder_name
26
30
  attr_reader :account_number_ending
@@ -4,5 +4,5 @@ end
4
4
 
5
5
  module GoCardlessPro
6
6
  # Current version of the GC gem
7
- VERSION = '2.10.0'.freeze
7
+ VERSION = '2.11.0'.freeze
8
8
  end
@@ -0,0 +1,97 @@
1
+ require 'openssl'
2
+
3
+ module GoCardlessPro
4
+ class Webhook
5
+ class InvalidSignatureError < StandardError; end
6
+
7
+ class << self
8
+ # Validates that a webhook was genuinely sent by GoCardless using
9
+ # `.signature_valid?`, and then parses it into an array of
10
+ # `GoCardlessPro::Resources::Event` objects representing each event
11
+ # included in the webhook
12
+ #
13
+ # @option options [String] :request_body the request body
14
+ # @option options [String] :signature_header the signature included in the request,
15
+ # found in the `Webhook-Signature` header
16
+ # @option options [String] :webhook_endpoint_secret the webhook endpoint secret for
17
+ # your webhook endpoint, as configured in your GoCardless Dashboard
18
+ # @return [Array<GoCardlessPro::Resources::Event>] the events included
19
+ # in the webhook
20
+ # @raise [InvalidSignatureError] if the signature header specified does not match
21
+ # the signature computed using the request body and webhook endpoint secret
22
+ # @raise [ArgumentError] if a required keyword argument is not provided or is not
23
+ # of the required type
24
+ def parse(options = {})
25
+ validate_options!(options)
26
+
27
+ unless signature_valid?(request_body: options[:request_body],
28
+ signature_header: options[:signature_header],
29
+ webhook_endpoint_secret: options[:webhook_endpoint_secret])
30
+ raise InvalidSignatureError, "This webhook doesn't appear to be a genuine " \
31
+ 'webhook from GoCardless, because the signature ' \
32
+ "header doesn't match the signature computed" \
33
+ ' with your webhook endpoint secret.'
34
+ end
35
+
36
+ events = JSON.parse(options[:request_body])['events']
37
+
38
+ events.map { |event| Resources::Event.new(event) }
39
+ end
40
+
41
+ # Validates that a webhook was genuinely sent by GoCardless by computing its
42
+ # signature using the body and your webhook endpoint secret, and comparing that with
43
+ # the signature included in the `Webhook-Signature` header
44
+ #
45
+ # @option options [String] :request_body the request body
46
+ # @option options [String] :signature_header the signature included in the request,
47
+ # found in the `Webhook-Signature` header
48
+ # @option options [String] :webhook_endpoint_secret the webhook endpoint secret for
49
+ # your webhook endpoint, as configured in your GoCardless Dashboard
50
+ # @return [Boolean] whether the webhook's signature is valid
51
+ # @raise [ArgumentError] if a required keyword argument is not provided or is not
52
+ # of the required type
53
+ def signature_valid?(options = {})
54
+ validate_options!(options)
55
+
56
+ computed_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'),
57
+ options[:webhook_endpoint_secret],
58
+ options[:request_body])
59
+
60
+ secure_compare(options[:signature_header], computed_signature)
61
+ end
62
+
63
+ private
64
+
65
+ # Performs a "constant time" comparison of two strings, safe against timing attacks
66
+ #
67
+ # Vendored from Rack's `Rack::Utils.secure_compare`
68
+ # (https://github.com/rack/rack/blob/eb040cf1bbb1b2dacd496ab0aa549de8408d8a27/lib/rack/utils.rb#L368-L382).
69
+ # Licensed under The MIT License (MIT). Copyright (C) 2007-2018 Christian
70
+ # Neukirchen.
71
+ def secure_compare(a, b)
72
+ return false unless a.bytesize == b.bytesize
73
+ l = a.unpack('C*')
74
+
75
+ r = 0
76
+ i = -1
77
+ b.each_byte { |v| r |= v ^ l[i += 1] }
78
+ r == 0
79
+ end
80
+
81
+ def validate_options!(options)
82
+ unless options[:request_body].is_a?(String)
83
+ raise ArgumentError, 'request_body must be provided and must be a string'
84
+ end
85
+
86
+ unless options[:signature_header].is_a?(String)
87
+ raise ArgumentError, 'signature_header must be provided and must be a string'
88
+ end
89
+
90
+ unless options[:webhook_endpoint_secret].is_a?(String)
91
+ raise ArgumentError, 'webhook_endpoint_secret must be provided and must be a ' \
92
+ 'string'
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,122 @@
1
+ require 'spec_helper'
2
+ require 'pathname'
3
+
4
+ describe GoCardlessPro::Webhook do
5
+ let(:options) do
6
+ {
7
+ request_body: request_body,
8
+ signature_header: signature_header,
9
+ webhook_endpoint_secret: webhook_endpoint_secret,
10
+ }
11
+ end
12
+
13
+ let(:request_body) do
14
+ '{"events":[{"id":"EV00BD05S5VM2T","created_at":"2018-07-05T09:13:51.404Z","resou' \
15
+ 'rce_type":"subscriptions","action":"created","links":{"subscription":"SB0003JJQ2' \
16
+ 'MR06"},"details":{"origin":"api","cause":"subscription_created","description":"S' \
17
+ 'ubscription created via the API."},"metadata":{}},{"id":"EV00BD05TB8K63","create' \
18
+ 'd_at":"2018-07-05T09:13:56.893Z","resource_type":"mandates","action":"created","' \
19
+ 'links":{"mandate":"MD000AMA19XGEC"},"details":{"origin":"api","cause":"mandate_c' \
20
+ 'reated","description":"Mandate created via the API."},"metadata":{}}]}'
21
+ end
22
+
23
+ let(:signature_header) do
24
+ '2693754819d3e32d7e8fcb13c729631f316c6de8dc1cf634d6527f1c07276e7e'
25
+ end
26
+
27
+ let(:webhook_endpoint_secret) { 'ED7D658C-D8EB-4941-948B-3973214F2D49' }
28
+
29
+ describe '.parse' do
30
+ context 'when the signature in the header matches the computed signature' do
31
+ it 'returns an array of `GoCardlessPro::Resources::Event`s' do
32
+ events = described_class.parse(options)
33
+
34
+ expect(events.length).to eq(2)
35
+
36
+ expect(events.first.id).to eq('EV00BD05S5VM2T')
37
+ expect(events.first.created_at).to eq('2018-07-05T09:13:51.404Z')
38
+ expect(events.first.resource_type).to eq('subscriptions')
39
+ expect(events.first.action).to eq('created')
40
+ expect(events.first.links.subscription).to eq('SB0003JJQ2MR06')
41
+ expect(events.first.details['origin']).to eq('api')
42
+ expect(events.first.details['cause']).to eq('subscription_created')
43
+ expect(events.first.details['description']).
44
+ to eq('Subscription created via the API.')
45
+ expect(events.first.metadata).to eq({})
46
+
47
+ expect(events.last.id).to eq('EV00BD05TB8K63')
48
+ expect(events.last.created_at).to eq('2018-07-05T09:13:56.893Z')
49
+ expect(events.last.resource_type).to eq('mandates')
50
+ expect(events.last.action).to eq('created')
51
+ expect(events.last.links.mandate).to eq('MD000AMA19XGEC')
52
+ expect(events.last.details['origin']).to eq('api')
53
+ expect(events.last.details['cause']).to eq('mandate_created')
54
+ expect(events.last.details['description']).
55
+ to eq('Mandate created via the API.')
56
+ expect(events.last.metadata).to eq({})
57
+ end
58
+ end
59
+
60
+ context "when the signature in the header doesn't match the computed signature" do
61
+ let(:webhook_endpoint_secret) { 'foo' }
62
+
63
+ it 'raises an InvalidSignatureError' do
64
+ expect { described_class.parse(options) }.
65
+ to raise_error(described_class::InvalidSignatureError,
66
+ /doesn't appear to be a genuine webhook from GoCardless/)
67
+ end
68
+ end
69
+
70
+ context 'with a required argument missing' do
71
+ before { options.delete(:request_body) }
72
+
73
+ it 'raises an ArgumentError' do
74
+ expect { described_class.signature_valid?(options) }.
75
+ to raise_error(ArgumentError,
76
+ 'request_body must be provided and must be a string')
77
+ end
78
+ end
79
+
80
+ context 'with an argument of the wrong type' do
81
+ let(:request_body) { StringIO.new }
82
+
83
+ it 'raises an ArgumentError' do
84
+ expect { described_class.signature_valid?(options) }.
85
+ to raise_error(ArgumentError,
86
+ 'request_body must be provided and must be a string')
87
+ end
88
+ end
89
+ end
90
+
91
+ describe '.signature_valid?' do
92
+ context 'when the signature in the header matches the computed signature' do
93
+ specify { expect(described_class.signature_valid?(options)).to be(true) }
94
+ end
95
+
96
+ context "when the signature in the header doesn't match the computed signature" do
97
+ let(:webhook_endpoint_secret) { 'foo' }
98
+
99
+ specify { expect(described_class.signature_valid?(options)).to be(false) }
100
+ end
101
+
102
+ context 'with a required argument missing' do
103
+ before { options.delete(:request_body) }
104
+
105
+ it 'raises an ArgumentError' do
106
+ expect { described_class.signature_valid?(options) }.
107
+ to raise_error(ArgumentError,
108
+ 'request_body must be provided and must be a string')
109
+ end
110
+ end
111
+
112
+ context 'with an argument of the wrong type' do
113
+ let(:request_body) { StringIO.new }
114
+
115
+ it 'raises an ArgumentError' do
116
+ expect { described_class.signature_valid?(options) }.
117
+ to raise_error(ArgumentError,
118
+ 'request_body must be provided and must be a string')
119
+ end
120
+ end
121
+ end
122
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gocardless_pro
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.10.0
4
+ version: 2.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GoCardless
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-06-06 00:00:00.000000000 Z
11
+ date: 2018-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -151,6 +151,7 @@ files:
151
151
  - lib/gocardless_pro/services/refunds_service.rb
152
152
  - lib/gocardless_pro/services/subscriptions_service.rb
153
153
  - lib/gocardless_pro/version.rb
154
+ - lib/gocardless_pro/webhook.rb
154
155
  - spec/api_response_spec.rb
155
156
  - spec/api_service_spec.rb
156
157
  - spec/client_spec.rb
@@ -190,6 +191,7 @@ files:
190
191
  - spec/services/refunds_service_spec.rb
191
192
  - spec/services/subscriptions_service_spec.rb
192
193
  - spec/spec_helper.rb
194
+ - spec/webhook_spec.rb
193
195
  homepage: https://github.com/gocardless/gocardless-pro-ruby
194
196
  licenses:
195
197
  - MIT
@@ -254,3 +256,4 @@ test_files:
254
256
  - spec/services/refunds_service_spec.rb
255
257
  - spec/services/subscriptions_service_spec.rb
256
258
  - spec/spec_helper.rb
259
+ - spec/webhook_spec.rb