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 +4 -4
- data/README.md +54 -0
- data/lib/gocardless_pro.rb +1 -0
- data/lib/gocardless_pro/client.rb +1 -1
- data/lib/gocardless_pro/resources/creditor_bank_account.rb +4 -0
- data/lib/gocardless_pro/version.rb +1 -1
- data/lib/gocardless_pro/webhook.rb +97 -0
- data/spec/webhook_spec.rb +122 -0
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e050e363d51abaf3c39a5f492efa1512145e38fd83b6ca89271c5a36912d58fb
|
4
|
+
data.tar.gz: 5570e9de8ac349af06b99a282246e69ff4df075826414f5bf9f2df06b65a52f2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
data/lib/gocardless_pro.rb
CHANGED
@@ -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.
|
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
|
@@ -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.
|
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-
|
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
|