gocardless_pro 2.10.0 → 2.11.0

Sign up to get free protection for your applications and to get access to all the features.
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