paypal-rest-api 0.0.4 → 0.1.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 +108 -3
- data/VERSION +1 -1
- data/lib/paypal-api/client.rb +68 -5
- data/lib/paypal-api/config.rb +6 -2
- data/lib/paypal-api/request.rb +53 -21
- data/lib/paypal-api/request_executor.rb +81 -66
- data/lib/paypal-api/response.rb +42 -5
- data/lib/paypal-api/webhook_verifier/certs_cache.rb +75 -0
- data/lib/paypal-api/webhook_verifier.rb +104 -0
- data/lib/paypal-api.rb +14 -0
- metadata +5 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 161078b6b9ff7513fe6634bc4e37c181374858dec0cac4261d5cc662d115787c
|
4
|
+
data.tar.gz: 60346804bbd8a930f695a922a6ce07adaefca26e25f7777e70de0d9f5d87a01a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e340f1e223aa903aed6fde693f4a16dc9cdf0ef3d474a633e171b7890eb016d8c42ef593117385419bd07c366bb257bbc0adbbcab8b193cb150523cd3dcaee46
|
7
|
+
data.tar.gz: 74f017ec1aabca6d9a55599bacc32ee7d7d2c425ec109ec076d2f8072819d95047a07d763ed7b5ba0bd4b96a3b91a3aa3b2404d1a0602c2704028661b544a18b
|
data/README.md
CHANGED
@@ -8,11 +8,14 @@ bundle add paypal-rest-api
|
|
8
8
|
|
9
9
|
## Features
|
10
10
|
|
11
|
+
- Supported Ruby 2.6 - current Head
|
11
12
|
- No dependencies;
|
12
13
|
- Automatic authorization & reauthorization;
|
13
14
|
- Auto-retries (configured);
|
14
15
|
- Automatically added Paypal-Request-Id header for idempotent requests if not
|
15
16
|
provided;
|
17
|
+
- Webhooks Offline verification (needs to download certificate once)
|
18
|
+
- Custom callbacks before/after request
|
16
19
|
|
17
20
|
## Usage
|
18
21
|
|
@@ -71,11 +74,11 @@ response = PaypalAPI.delete(path, query: query, body: body, headers: headers)
|
|
71
74
|
|
72
75
|
### Parsing response
|
73
76
|
|
74
|
-
`response.body` is a main method that returns parsed JSON respoonse as a
|
77
|
+
`response.body` is a main method that returns parsed JSON respoonse as a Hash.
|
75
78
|
|
76
79
|
There are also many others helpful methods:
|
77
80
|
|
78
|
-
```
|
81
|
+
```ruby
|
79
82
|
response.body # Parsed JSON. JSON is parsed lazyly, keys are symbolized.
|
80
83
|
response[:foo] # Gets :foo attribute from parsed body
|
81
84
|
response.fetch(:foo) # Fetches :foo attribute from parsed body
|
@@ -83,7 +86,7 @@ response.http_response # original Net::HTTP::Response
|
|
83
86
|
response.http_body # original response string
|
84
87
|
response.http_status # Integer http status
|
85
88
|
response.http_headers # Hash with response headers (keys are strings)
|
86
|
-
response.
|
89
|
+
response.request # Request that generates this response
|
87
90
|
```
|
88
91
|
|
89
92
|
## Configuration options
|
@@ -136,6 +139,108 @@ client = PaypalAPI::Client.new(
|
|
136
139
|
)
|
137
140
|
```
|
138
141
|
|
142
|
+
## Webhoooks verification
|
143
|
+
|
144
|
+
Webhooks can be verified [offline](https://developer.paypal.com/api/rest/webhooks/rest/#link-selfverificationmethod)
|
145
|
+
or [online](https://developer.paypal.com/api/rest/webhooks/rest/#link-postbackmethod).
|
146
|
+
Method `PaypalAPI.verify_webhook(webhook_id:, headers:, raw_body:)`
|
147
|
+
verifies webhook. It to verify webhook OFFLINE and it fallbacks
|
148
|
+
to ONLINE if offline verification returns false to be sure you don't miss a
|
149
|
+
valid webhook.
|
150
|
+
|
151
|
+
When some required header is missing it will raise
|
152
|
+
`PaypalAPI::WebhooksVerifier::MissingHeader` error.
|
153
|
+
|
154
|
+
Example of Rails controller with webhook verification:
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
class Webhooks::PaypalController < ApplicationController
|
158
|
+
def create
|
159
|
+
# PayPal registered webhook ID for current URL
|
160
|
+
webhook_id = ENV['PAYPAL_WEBHOOK_ID']
|
161
|
+
headers = request.headers # must be a Hash
|
162
|
+
raw_body = request.raw_post # must be a raw String body
|
163
|
+
|
164
|
+
webhook_is_valid = PaypalAPI.verify_webhook(
|
165
|
+
webhook_id: webhook_id,
|
166
|
+
headers: headers,
|
167
|
+
raw_body: raw_body
|
168
|
+
)
|
169
|
+
|
170
|
+
if webhook_is_valid
|
171
|
+
handle_valid_webhook_event(body)
|
172
|
+
else
|
173
|
+
handle_invalid_webhook_event(webhook_id, headers, body)
|
174
|
+
end
|
175
|
+
|
176
|
+
head :no_content
|
177
|
+
end
|
178
|
+
end
|
179
|
+
```
|
180
|
+
|
181
|
+
## Callbacks
|
182
|
+
|
183
|
+
Callbacks list:
|
184
|
+
|
185
|
+
- `:before` - Runs before request
|
186
|
+
- `:after_success` - Runs after getting successful response
|
187
|
+
- `:after_fail` - Runs after getting failed response (non-2xx) status code
|
188
|
+
- `:after_network_error` - Runs after getting network error
|
189
|
+
|
190
|
+
Callbacks are registered on `client` object.
|
191
|
+
|
192
|
+
Each callback receive `request` and `context` variables.
|
193
|
+
`context` can be modified manually to save state between callbacks.
|
194
|
+
|
195
|
+
`:after_success` and `:after_fail` callbacks have additional `response` argument
|
196
|
+
|
197
|
+
`:after_network_error` callback has additional `error` argument
|
198
|
+
|
199
|
+
```ruby
|
200
|
+
PaypalAPI.client.add_callback(:before) do |request, context|
|
201
|
+
context[:request_id] = SecureRandom.hex(3)
|
202
|
+
context[:starts_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
203
|
+
end
|
204
|
+
|
205
|
+
PaypalAPI.client.add_callback(:after) do |request, context, response|
|
206
|
+
ends_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
207
|
+
duration = ends_at - context[:starts_at]
|
208
|
+
|
209
|
+
SomeLogger.debug(
|
210
|
+
'PaypalAPI success request',
|
211
|
+
method: request.method,
|
212
|
+
uri: request.uri.to_s,
|
213
|
+
duration: duration
|
214
|
+
)
|
215
|
+
end
|
216
|
+
|
217
|
+
PaypalAPI.client.add_callback(:after_fail) do |request, context, response|
|
218
|
+
SomeLogger.error(
|
219
|
+
'PaypalAPI request failed',
|
220
|
+
method: request.method,
|
221
|
+
uri: request.uri.to_s,
|
222
|
+
response_status: response.http_status,
|
223
|
+
response_body: response.http_body,
|
224
|
+
will_retry: context[:will_retry],
|
225
|
+
retry_number: context[:retry_number],
|
226
|
+
retry_count: context[:retry_count]
|
227
|
+
)
|
228
|
+
end
|
229
|
+
|
230
|
+
PaypalAPI.client.add_callback(:after_network_error) do |request, context, error|
|
231
|
+
SomeLogger.error(
|
232
|
+
'PaypalAPI network connection error'
|
233
|
+
method: request.method,
|
234
|
+
uri: request.uri.to_s,
|
235
|
+
error: error.message,
|
236
|
+
paypal_request_id: request.headers['paypal-request-id'],
|
237
|
+
will_retry: context[:will_retry],
|
238
|
+
retry_number: context[:retry_number],
|
239
|
+
retry_count: context[:retry_count]
|
240
|
+
)
|
241
|
+
end
|
242
|
+
```
|
243
|
+
|
139
244
|
## Errors
|
140
245
|
|
141
246
|
All APIs can raise error in case of network error or non-2xx response status code.
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.0
|
1
|
+
0.1.0
|
data/lib/paypal-api/client.rb
CHANGED
@@ -5,7 +5,7 @@ module PaypalAPI
|
|
5
5
|
# PaypalAPI Client
|
6
6
|
#
|
7
7
|
class Client
|
8
|
-
attr_reader :config
|
8
|
+
attr_reader :config, :callbacks
|
9
9
|
|
10
10
|
# Initializes Client
|
11
11
|
# @api public
|
@@ -18,18 +18,49 @@ module PaypalAPI
|
|
18
18
|
#
|
19
19
|
# @return [Client] Initialized client
|
20
20
|
#
|
21
|
-
def initialize(client_id:, client_secret:, live: nil, http_opts: nil, retries: nil)
|
21
|
+
def initialize(client_id:, client_secret:, live: nil, http_opts: nil, retries: nil, cache: nil)
|
22
22
|
@config = PaypalAPI::Config.new(
|
23
23
|
client_id: client_id,
|
24
24
|
client_secret: client_secret,
|
25
25
|
live: live,
|
26
26
|
http_opts: http_opts,
|
27
|
-
retries: retries
|
27
|
+
retries: retries,
|
28
|
+
cache: cache
|
28
29
|
)
|
29
30
|
|
31
|
+
@callbacks = {
|
32
|
+
before: [],
|
33
|
+
after_success: [],
|
34
|
+
after_fail: [],
|
35
|
+
after_network_error: []
|
36
|
+
}.freeze
|
37
|
+
|
30
38
|
@access_token = nil
|
31
39
|
end
|
32
40
|
|
41
|
+
# Registers callback
|
42
|
+
#
|
43
|
+
# @param callback_name [Symbol] Callback name.
|
44
|
+
# Allowed values: :before, :after_success, :after_faile, :after_network_error
|
45
|
+
#
|
46
|
+
# @param block [Proc] Block that must be call
|
47
|
+
# For `:before` callback proc should accept 2 params -
|
48
|
+
# request [Request], context [Hash]
|
49
|
+
#
|
50
|
+
# For `:after_success` callback proc should accept 3 params -
|
51
|
+
# request [Request], context [Hash], response [Response]
|
52
|
+
#
|
53
|
+
# For `:after_fail` callback proc should accept 3 params -
|
54
|
+
# request [Request], context [Hash], error [StandardError]
|
55
|
+
#
|
56
|
+
# For `:after_network_error` callback proc should accept 3 params -
|
57
|
+
# request [Request], context [Hash], response [Response]
|
58
|
+
#
|
59
|
+
# @return [void]
|
60
|
+
def add_callback(callback_name, &block)
|
61
|
+
callbacks.fetch(callback_name) << block
|
62
|
+
end
|
63
|
+
|
33
64
|
#
|
34
65
|
# Checks cached access token is expired and returns it or generates new one
|
35
66
|
#
|
@@ -45,16 +76,48 @@ module PaypalAPI
|
|
45
76
|
# @return [AccessToken] new AccessToken object
|
46
77
|
#
|
47
78
|
def refresh_access_token
|
79
|
+
requested_at = Time.now
|
48
80
|
response = authentication.generate_access_token
|
49
81
|
|
50
82
|
@access_token = AccessToken.new(
|
51
|
-
requested_at:
|
83
|
+
requested_at: requested_at,
|
52
84
|
expires_in: response.fetch(:expires_in),
|
53
85
|
access_token: response.fetch(:access_token),
|
54
86
|
token_type: response.fetch(:token_type)
|
55
87
|
)
|
56
88
|
end
|
57
89
|
|
90
|
+
#
|
91
|
+
# Verifies Webhook
|
92
|
+
# It requires one-time request to download and cache certificate.
|
93
|
+
# If local verification returns false it tries to verify webhook online.
|
94
|
+
#
|
95
|
+
# @api public
|
96
|
+
# @example
|
97
|
+
#
|
98
|
+
# class Webhooks::PaypalController < ApplicationController
|
99
|
+
# def create
|
100
|
+
# webhook_id = ENV['PAYPAL_WEBHOOK_ID'] # PayPal registered webhook ID for current URL
|
101
|
+
# headers = request.headers # must be a Hash
|
102
|
+
# body = request.raw_post # must be a raw String body
|
103
|
+
#
|
104
|
+
# webhook_is_valid = PaypalAPI.verify_webhook(webhook_id: webhook_id, headers: headers, body: body)
|
105
|
+
# webhook_is_valid ? handle_webhook_event(body) : log_error(webhook_id, headers, body)
|
106
|
+
#
|
107
|
+
# head :no_content
|
108
|
+
# end
|
109
|
+
# end
|
110
|
+
#
|
111
|
+
# @param webhook_id [String] webhook_id provided by PayPal when webhook was registered
|
112
|
+
# @param headers [Hash,#[]] webhook request headers
|
113
|
+
# @param raw_body [String] webhook request raw body string
|
114
|
+
#
|
115
|
+
# @return [Boolean] webhook event is valid
|
116
|
+
#
|
117
|
+
def verify_webhook(webhook_id:, headers:, raw_body:)
|
118
|
+
WebhookVerifier.new(self).call(webhook_id: webhook_id, headers: headers, raw_body: raw_body)
|
119
|
+
end
|
120
|
+
|
58
121
|
# @!macro [new] request
|
59
122
|
# @param path [String] Request path
|
60
123
|
# @param query [Hash, nil] Request query parameters
|
@@ -217,7 +280,7 @@ module PaypalAPI
|
|
217
280
|
|
218
281
|
def execute_request(http_method, path, query: nil, body: nil, headers: nil)
|
219
282
|
request = Request.new(self, http_method, path, query: query, body: body, headers: headers)
|
220
|
-
RequestExecutor.
|
283
|
+
RequestExecutor.new(self, request).call
|
221
284
|
end
|
222
285
|
end
|
223
286
|
end
|
data/lib/paypal-api/config.rb
CHANGED
@@ -18,7 +18,7 @@ module PaypalAPI
|
|
18
18
|
retries: {enabled: true, count: 3, sleep: [0.25, 0.75, 1.5].freeze}.freeze
|
19
19
|
}.freeze
|
20
20
|
|
21
|
-
attr_reader :client_id, :client_secret, :live, :http_opts, :retries
|
21
|
+
attr_reader :client_id, :client_secret, :live, :http_opts, :retries, :certs_cache
|
22
22
|
|
23
23
|
# Initializes Config
|
24
24
|
#
|
@@ -27,15 +27,19 @@ module PaypalAPI
|
|
27
27
|
# @param live [Boolean] PayPal live/sandbox mode
|
28
28
|
# @param http_opts [Hash] Net::Http opts for all requests
|
29
29
|
# @param retries [Hash] Retries configuration
|
30
|
+
# @param cache [#read, nil] Application cache to store certificates to validate webhook events locally.
|
31
|
+
# Must respond to #read(key) and #write(key, expires_in: Integer)
|
30
32
|
#
|
31
33
|
# @return [Client] Initialized config object
|
32
34
|
#
|
33
|
-
def initialize(client_id:, client_secret:, live: nil, http_opts: nil, retries: nil)
|
35
|
+
def initialize(client_id:, client_secret:, live: nil, http_opts: nil, retries: nil, cache: nil)
|
34
36
|
@client_id = client_id
|
35
37
|
@client_secret = client_secret
|
36
38
|
@live = with_default(:live, live)
|
37
39
|
@http_opts = with_default(:http_opts, http_opts)
|
38
40
|
@retries = with_default(:retries, retries)
|
41
|
+
@certs_cache = WebhookVerifier::CertsCache.new(cache)
|
42
|
+
|
39
43
|
freeze
|
40
44
|
end
|
41
45
|
|
data/lib/paypal-api/request.rb
CHANGED
@@ -19,8 +19,8 @@ module PaypalAPI
|
|
19
19
|
# @return [Net::HTTPRequest] Generated Net::HTTPRequest
|
20
20
|
attr_reader :http_request
|
21
21
|
|
22
|
-
# @return [
|
23
|
-
attr_accessor :
|
22
|
+
# @return [Hash, nil, Object] Custom context that can be set/changed in callbacks
|
23
|
+
attr_accessor :context
|
24
24
|
|
25
25
|
# rubocop:disable Metrics/ParameterLists
|
26
26
|
|
@@ -40,48 +40,80 @@ module PaypalAPI
|
|
40
40
|
def initialize(client, request_type, path, body: nil, query: nil, headers: nil)
|
41
41
|
@client = client
|
42
42
|
@http_request = build_http_request(request_type, path, body: body, query: query, headers: headers)
|
43
|
-
@
|
43
|
+
@context = nil
|
44
44
|
end
|
45
45
|
# rubocop:enable Metrics/ParameterLists
|
46
46
|
|
47
|
+
# @return [String] HTTP request method name
|
48
|
+
def method
|
49
|
+
http_request.method
|
50
|
+
end
|
51
|
+
|
52
|
+
# @return [String] HTTP request method name
|
53
|
+
def path
|
54
|
+
http_request.path
|
55
|
+
end
|
56
|
+
|
57
|
+
# @return [URI] HTTP request URI
|
58
|
+
def uri
|
59
|
+
http_request.uri
|
60
|
+
end
|
61
|
+
|
62
|
+
# @return [String] HTTP request body
|
63
|
+
def body
|
64
|
+
http_request.body
|
65
|
+
end
|
66
|
+
|
67
|
+
# @return [Hash] HTTP request headers
|
68
|
+
def headers
|
69
|
+
http_request.each_header.to_h
|
70
|
+
end
|
71
|
+
|
47
72
|
private
|
48
73
|
|
49
74
|
def build_http_request(request_type, path, body:, query:, headers:)
|
50
|
-
uri =
|
51
|
-
http_request = request_type.new(uri)
|
75
|
+
uri = build_http_uri(path, query)
|
76
|
+
http_request = request_type.new(uri, "accept-encoding" => nil)
|
52
77
|
|
53
|
-
|
54
|
-
|
78
|
+
build_http_headers(http_request, body, headers || {})
|
79
|
+
build_http_body(http_request, body)
|
55
80
|
|
56
81
|
http_request
|
57
82
|
end
|
58
83
|
|
59
|
-
def
|
60
|
-
headers
|
84
|
+
def build_http_headers(http_request, body, headers)
|
85
|
+
headers = normalize_headers(headers)
|
86
|
+
|
87
|
+
unless headers.key?("authorization")
|
88
|
+
http_request["authorization"] = client.access_token.authorization_string
|
89
|
+
end
|
90
|
+
|
91
|
+
unless headers.key?("content-type")
|
92
|
+
http_request["content-type"] = "application/json" if body
|
93
|
+
end
|
61
94
|
|
62
|
-
|
63
|
-
|
64
|
-
|
95
|
+
unless headers.key?("paypal-request-id")
|
96
|
+
http_request["paypal-request-id"] = SecureRandom.uuid unless http_request.is_a?(Net::HTTP::Get)
|
97
|
+
end
|
98
|
+
|
99
|
+
headers.each { |key, value| http_request[key] = value }
|
65
100
|
end
|
66
101
|
|
67
|
-
def
|
102
|
+
def build_http_body(http_request, body)
|
68
103
|
return unless body
|
69
104
|
|
70
|
-
|
105
|
+
is_json = http_request["content-type"].include?("json")
|
106
|
+
is_json ? http_request.body = JSON.dump(body) : http_request.set_form_data(body)
|
71
107
|
end
|
72
108
|
|
73
|
-
def
|
109
|
+
def build_http_uri(path, query)
|
74
110
|
uri = URI.join(client.config.url, path)
|
75
111
|
uri.query = URI.encode_www_form(query) if query && !query.empty?
|
76
112
|
uri
|
77
113
|
end
|
78
114
|
|
79
|
-
def
|
80
|
-
|
81
|
-
end
|
82
|
-
|
83
|
-
def json?(http_request)
|
84
|
-
http_request["content-type"].include?("json")
|
115
|
+
def normalize_headers(headers)
|
116
|
+
headers.empty? ? headers : headers.transform_keys { |key| key.to_s.downcase }
|
85
117
|
end
|
86
118
|
end
|
87
119
|
end
|
@@ -5,91 +5,106 @@ module PaypalAPI
|
|
5
5
|
# Executes PaypalAPI::Request and returns PaypalAPI::Response or raises PaypalAPI::Error
|
6
6
|
#
|
7
7
|
class RequestExecutor
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
#
|
19
|
-
# @param [Request] request
|
20
|
-
#
|
21
|
-
# @return [Response] Response
|
22
|
-
#
|
23
|
-
def call(request)
|
24
|
-
http_response = execute(request)
|
25
|
-
response = Response.new(http_response, requested_at: request.requested_at)
|
26
|
-
raise FailedRequestErrorBuilder.call(request: request, response: response) unless http_response.is_a?(Net::HTTPSuccess)
|
8
|
+
attr_reader :client, :request, :http_opts, :context, :retries, :callbacks, :callbacks_context
|
9
|
+
|
10
|
+
def initialize(client, request)
|
11
|
+
@client = client
|
12
|
+
@request = request
|
13
|
+
@http_opts = {use_ssl: request.uri.is_a?(URI::HTTPS), **client.config.http_opts}
|
14
|
+
@retries = client.config.retries
|
15
|
+
@callbacks = client.callbacks
|
16
|
+
@callbacks_context = {retries_count: retries[:count]}
|
17
|
+
end
|
27
18
|
|
28
|
-
|
29
|
-
|
19
|
+
#
|
20
|
+
# Executes prepared Request, handles retries and preparation of errors
|
21
|
+
#
|
22
|
+
# @return [Response] Response
|
23
|
+
#
|
24
|
+
def call
|
25
|
+
response = execute_request
|
26
|
+
raise FailedRequestErrorBuilder.call(request: request, response: response) if response.failed?
|
27
|
+
|
28
|
+
response
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
30
32
|
|
31
|
-
|
33
|
+
def execute_request(retry_number: 0)
|
34
|
+
callbacks_context[:retry_number] = retry_number
|
32
35
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
36
|
+
run_callbacks(:before)
|
37
|
+
response = execute_net_http_request
|
38
|
+
rescue *NetworkErrorBuilder::ERRORS => error
|
39
|
+
will_retry = !retries_limit_reached?(retry_number)
|
40
|
+
callbacks_context[:will_retry] = will_retry
|
41
|
+
run_callbacks(:after_network_error, error)
|
42
|
+
raise NetworkErrorBuilder.call(request: request, error: error) unless will_retry
|
43
|
+
|
44
|
+
retry_request(retry_number)
|
45
|
+
else
|
46
|
+
if response.success?
|
47
|
+
callbacks_context.delete(:will_retry)
|
48
|
+
run_callbacks(:after_success, response)
|
49
|
+
response
|
37
50
|
else
|
38
|
-
|
51
|
+
will_retry = retryable?(response, retry_number)
|
52
|
+
callbacks_context[:will_retry] = will_retry
|
53
|
+
run_callbacks(:after_fail, response)
|
54
|
+
will_retry ? retry_request(retry_number) : response
|
39
55
|
end
|
56
|
+
end
|
40
57
|
|
41
|
-
|
42
|
-
|
43
|
-
http_opts = request.client.config.http_opts
|
44
|
-
uri = http_request.uri
|
45
|
-
request.requested_at = Time.now
|
58
|
+
def execute_net_http_request
|
59
|
+
uri = request.uri
|
46
60
|
|
47
|
-
|
61
|
+
http_response =
|
62
|
+
Net::HTTP.start(uri.hostname, uri.port, **http_opts) do |http|
|
48
63
|
http.max_retries = 0 # we have custom retries logic
|
49
|
-
http.request(http_request)
|
64
|
+
http.request(request.http_request)
|
50
65
|
end
|
51
|
-
end
|
52
66
|
|
53
|
-
|
54
|
-
|
67
|
+
Response.new(http_response, request: request)
|
68
|
+
end
|
55
69
|
|
56
|
-
|
57
|
-
|
70
|
+
def retry_request(current_retry_number)
|
71
|
+
sleep_time = retry_sleep_seconds(current_retry_number)
|
72
|
+
sleep(sleep_time) if sleep_time.positive?
|
73
|
+
execute_request(retry_number: current_retry_number + 1)
|
74
|
+
end
|
58
75
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
end
|
76
|
+
def retries_limit_reached?(retry_number)
|
77
|
+
retry_number >= retries[:count]
|
78
|
+
end
|
63
79
|
|
64
|
-
|
65
|
-
|
66
|
-
|
80
|
+
def retry_sleep_seconds(current_retry_number)
|
81
|
+
seconds_per_retry = retries[:sleep]
|
82
|
+
seconds_per_retry[current_retry_number] || seconds_per_retry.last || 1
|
83
|
+
end
|
67
84
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
85
|
+
def retryable?(response, retry_number)
|
86
|
+
response.failed? &&
|
87
|
+
!retries_limit_reached?(retry_number) &&
|
88
|
+
retryable_request?(response)
|
89
|
+
end
|
72
90
|
|
73
|
-
|
74
|
-
|
75
|
-
!retries_limit_reached?(request, retry_number) &&
|
76
|
-
retryable_request?(request, http_response)
|
77
|
-
end
|
91
|
+
def retryable_request?(response)
|
92
|
+
return true if response.retryable?
|
78
93
|
|
79
|
-
|
80
|
-
|
94
|
+
retry_unauthorized?(response)
|
95
|
+
end
|
81
96
|
|
82
|
-
|
83
|
-
|
97
|
+
def retry_unauthorized?(response)
|
98
|
+
return false unless response.unauthorized? # 401
|
99
|
+
return false if request.path == Authentication::PATH # it's already an Authentication request
|
84
100
|
|
85
|
-
|
86
|
-
|
87
|
-
|
101
|
+
# set new access-token
|
102
|
+
request.http_request["authorization"] = client.refresh_access_token.authorization_string
|
103
|
+
true
|
104
|
+
end
|
88
105
|
|
89
|
-
|
90
|
-
|
91
|
-
true
|
92
|
-
end
|
106
|
+
def run_callbacks(callback_name, resp = nil)
|
107
|
+
callbacks[callback_name].each { |callback| callback.call(request, context, resp) }
|
93
108
|
end
|
94
109
|
end
|
95
110
|
end
|
data/lib/paypal-api/response.rb
CHANGED
@@ -7,22 +7,29 @@ module PaypalAPI
|
|
7
7
|
# PaypalAPI::Response object
|
8
8
|
#
|
9
9
|
class Response
|
10
|
+
# List of Net::HTTP responses that can be retried
|
11
|
+
RETRYABLE_RESPONSES = [
|
12
|
+
Net::HTTPServerError, # 5xx
|
13
|
+
Net::HTTPConflict, # 409
|
14
|
+
Net::HTTPTooManyRequests # 429
|
15
|
+
].freeze
|
16
|
+
|
10
17
|
# @return [Net::HTTP::Response] Original Net::HTTP::Response object
|
11
18
|
attr_reader :http_response
|
12
19
|
|
13
|
-
# @return [
|
14
|
-
attr_reader :
|
20
|
+
# @return [Request] Request object
|
21
|
+
attr_reader :request
|
15
22
|
|
16
23
|
#
|
17
24
|
# Initializes Response object
|
18
25
|
#
|
19
26
|
# @param http_response [Net::HTTP::Response] original response
|
20
|
-
# @param
|
27
|
+
# @param request [Request] Request that generates this response
|
21
28
|
#
|
22
29
|
# @return [Response] Initialized Response object
|
23
30
|
#
|
24
|
-
def initialize(http_response,
|
25
|
-
@
|
31
|
+
def initialize(http_response, request:)
|
32
|
+
@request = request
|
26
33
|
@http_response = http_response
|
27
34
|
@http_status = nil
|
28
35
|
@http_headers = nil
|
@@ -64,6 +71,36 @@ module PaypalAPI
|
|
64
71
|
data.fetch(key.to_sym)
|
65
72
|
end
|
66
73
|
|
74
|
+
# Checks http status code is 2xx
|
75
|
+
#
|
76
|
+
# @return [Boolean] Returns true if response has success status code (2xx)
|
77
|
+
def success?
|
78
|
+
http_response.is_a?(Net::HTTPSuccess)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Checks http status code is not 2xx
|
82
|
+
#
|
83
|
+
# @return [Boolean] Returns true if response has not success status code
|
84
|
+
def failed?
|
85
|
+
!success?
|
86
|
+
end
|
87
|
+
|
88
|
+
# Checks if response status code is retriable (5xx, 409, 429)
|
89
|
+
# @api private
|
90
|
+
#
|
91
|
+
# @return [Boolean] Returns true if status code is retriable (5xx, 409, 429)
|
92
|
+
def retryable?
|
93
|
+
failed? && RETRYABLE_RESPONSES.any? { |retryable_class| http_response.is_a?(retryable_class) }
|
94
|
+
end
|
95
|
+
|
96
|
+
# Checks if response status code is unauthorized (401)
|
97
|
+
# @api private
|
98
|
+
#
|
99
|
+
# @return [Boolean] Returns true if status code is retriable (5xx, 409, 429)
|
100
|
+
def unauthorized?
|
101
|
+
http_response.is_a?(Net::HTTPUnauthorized)
|
102
|
+
end
|
103
|
+
|
67
104
|
#
|
68
105
|
# Instance representation string. Default was overwritten to hide secrets
|
69
106
|
#
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PaypalAPI
|
4
|
+
class WebhookVerifier
|
5
|
+
#
|
6
|
+
# Stores certifiactes in-memory.
|
7
|
+
#
|
8
|
+
# New values are added to this in-memory cache and application cache.
|
9
|
+
# When fetching value it firstly looks to the memory cache and then to the app cache.
|
10
|
+
#
|
11
|
+
# @api private
|
12
|
+
#
|
13
|
+
class CertsCache
|
14
|
+
# Current application cache
|
15
|
+
# @api private
|
16
|
+
attr_reader :app_cache
|
17
|
+
|
18
|
+
# Hash storage of certificate public keys
|
19
|
+
# @api private
|
20
|
+
attr_reader :storage
|
21
|
+
|
22
|
+
# Initializes certificates cache
|
23
|
+
#
|
24
|
+
# @param app_cache [#fetch, nil] Application cache that can store
|
25
|
+
# certificates between redeploys
|
26
|
+
#
|
27
|
+
# @return [CertsCache]
|
28
|
+
def initialize(app_cache)
|
29
|
+
@app_cache = app_cache || NullCache
|
30
|
+
@storage = {}
|
31
|
+
end
|
32
|
+
|
33
|
+
# Fetches value from cache
|
34
|
+
#
|
35
|
+
# @param key [String] Cache key
|
36
|
+
# @param block [Proc] Proc to fetch certificate text
|
37
|
+
#
|
38
|
+
# @return [OpenSSL::PKey::PKey] Certificate Public Key
|
39
|
+
def fetch(key, &block)
|
40
|
+
openssl_pub_key = read(key)
|
41
|
+
return openssl_pub_key if openssl_pub_key
|
42
|
+
|
43
|
+
cert_string = app_cache.fetch(key, &block)
|
44
|
+
cert = OpenSSL::X509::Certificate.new(cert_string)
|
45
|
+
|
46
|
+
write(key, cert.public_key)
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def write(key, value)
|
52
|
+
storage[key] = value
|
53
|
+
end
|
54
|
+
|
55
|
+
def read(key)
|
56
|
+
storage[key]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
#
|
61
|
+
# Null-object cache class.
|
62
|
+
# Implements only #read and #write method.
|
63
|
+
#
|
64
|
+
# @api private
|
65
|
+
#
|
66
|
+
class NullCache
|
67
|
+
# Just calls provided block
|
68
|
+
# @param _key [String] Cache key
|
69
|
+
# @return [String] block result
|
70
|
+
def self.fetch(_key)
|
71
|
+
yield
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "zlib"
|
4
|
+
|
5
|
+
module PaypalAPI
|
6
|
+
#
|
7
|
+
# Webhook Verifier
|
8
|
+
# @api private
|
9
|
+
#
|
10
|
+
class WebhookVerifier
|
11
|
+
# Error that can happen when verifying webhook when some required headers are missing
|
12
|
+
class MissingHeader < StandardError
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [Client] current client
|
16
|
+
attr_reader :client
|
17
|
+
|
18
|
+
def initialize(client)
|
19
|
+
@client = client
|
20
|
+
end
|
21
|
+
|
22
|
+
# Verifies Webhook.
|
23
|
+
#
|
24
|
+
# It requires one-time request to download and cache certificate.
|
25
|
+
# If local verification returns false it tries to verify webhook online.
|
26
|
+
#
|
27
|
+
# @param webhook_id [String] Webhook ID provided by PayPal when registering webhook for your URL
|
28
|
+
# @param headers [Hash] webhook request headers
|
29
|
+
# @param raw_body [String] webhook request raw body string
|
30
|
+
#
|
31
|
+
# @return [Boolean]
|
32
|
+
def call(webhook_id:, headers:, raw_body:)
|
33
|
+
args = {
|
34
|
+
webhook_id: webhook_id,
|
35
|
+
raw_body: raw_body,
|
36
|
+
auth_algo: paypal_auth_algo(headers),
|
37
|
+
transmission_sig: paypal_transmission_sig(headers),
|
38
|
+
transmission_id: paypal_transmission_id(headers),
|
39
|
+
transmission_time: paypal_transmission_time(headers),
|
40
|
+
cert_url: paypal_cert_url(headers)
|
41
|
+
}
|
42
|
+
|
43
|
+
offline(**args) || online(**args)
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def offline(webhook_id:, auth_algo:, transmission_sig:, transmission_id:, transmission_time:, cert_url:, raw_body:)
|
49
|
+
return false unless auth_algo.downcase.start_with?("sha256")
|
50
|
+
|
51
|
+
signature = paypal_signature(transmission_sig)
|
52
|
+
checked_data = "#{transmission_id}|#{transmission_time}|#{webhook_id}|#{Zlib.crc32(raw_body)}"
|
53
|
+
openssl_pub_key = download_and_cache_certificate(cert_url)
|
54
|
+
|
55
|
+
digest = OpenSSL::Digest.new("sha256", signature)
|
56
|
+
openssl_pub_key.verify(digest, signature, checked_data)
|
57
|
+
end
|
58
|
+
|
59
|
+
def online(webhook_id:, auth_algo:, transmission_sig:, transmission_id:, transmission_time:, cert_url:, raw_body:)
|
60
|
+
body = {
|
61
|
+
webhook_id: webhook_id,
|
62
|
+
auth_algo: auth_algo,
|
63
|
+
cert_url: cert_url,
|
64
|
+
transmission_id: transmission_id,
|
65
|
+
transmission_sig: transmission_sig,
|
66
|
+
transmission_time: transmission_time,
|
67
|
+
webhook_event: JSON.parse(raw_body)
|
68
|
+
}
|
69
|
+
|
70
|
+
response = client.webhooks.verify(body: body)
|
71
|
+
response[:verification_status] == "SUCCESS"
|
72
|
+
end
|
73
|
+
|
74
|
+
def paypal_signature(transmission_sig)
|
75
|
+
transmission_sig.unpack1("m") # decode base64
|
76
|
+
end
|
77
|
+
|
78
|
+
def paypal_transmission_sig(headers)
|
79
|
+
headers["paypal-transmission-sig"] || (raise MissingHeader, "No `paypal-transmission-sig` header")
|
80
|
+
end
|
81
|
+
|
82
|
+
def paypal_transmission_id(headers)
|
83
|
+
headers["paypal-transmission-id"] || (raise MissingHeader, "No `paypal-transmission-id` header")
|
84
|
+
end
|
85
|
+
|
86
|
+
def paypal_transmission_time(headers)
|
87
|
+
headers["paypal-transmission-time"] || (raise MissingHeader, "No `paypal-transmission-time` header")
|
88
|
+
end
|
89
|
+
|
90
|
+
def paypal_cert_url(headers)
|
91
|
+
headers["paypal-cert-url"] || (raise MissingHeader, "No `paypal-cert-url` header")
|
92
|
+
end
|
93
|
+
|
94
|
+
def paypal_auth_algo(headers)
|
95
|
+
headers["paypal-auth-algo"] || (raise MissingHeader, "No `paypal-auth-algo` header")
|
96
|
+
end
|
97
|
+
|
98
|
+
def download_and_cache_certificate(cert_url)
|
99
|
+
client.config.certs_cache.fetch("PaypalRestAPI.certificate.#{cert_url}") do
|
100
|
+
client.get(cert_url, headers: {"authorization" => nil}).http_body
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
data/lib/paypal-api.rb
CHANGED
@@ -62,6 +62,18 @@ module PaypalAPI
|
|
62
62
|
end
|
63
63
|
end
|
64
64
|
|
65
|
+
#
|
66
|
+
# Verifies Webhook
|
67
|
+
#
|
68
|
+
# It requires one-time request to download and cache certificate.
|
69
|
+
# If local verification returns false it tries to verify webhook online.
|
70
|
+
#
|
71
|
+
# @see Client#verify_webhook
|
72
|
+
#
|
73
|
+
def verify_webhook(webhook_id:, headers:, raw_body:)
|
74
|
+
client.verify_webhook(webhook_id: webhook_id, headers: headers, raw_body: raw_body)
|
75
|
+
end
|
76
|
+
|
65
77
|
#
|
66
78
|
# @!macro [new] api_collection
|
67
79
|
# $0 APIs collection
|
@@ -206,6 +218,8 @@ require_relative "paypal-api/network_error_builder"
|
|
206
218
|
require_relative "paypal-api/request"
|
207
219
|
require_relative "paypal-api/request_executor"
|
208
220
|
require_relative "paypal-api/response"
|
221
|
+
require_relative "paypal-api/webhook_verifier"
|
222
|
+
require_relative "paypal-api/webhook_verifier/certs_cache"
|
209
223
|
require_relative "paypal-api/api_collections/authentication"
|
210
224
|
require_relative "paypal-api/api_collections/authorized_payments"
|
211
225
|
require_relative "paypal-api/api_collections/captured_payments"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: paypal-rest-api
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrey Glushkov
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-08-
|
11
|
+
date: 2024-08-30 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: PayPal REST API with no dependencies.
|
14
14
|
email:
|
@@ -52,6 +52,8 @@ files:
|
|
52
52
|
- lib/paypal-api/request_executor.rb
|
53
53
|
- lib/paypal-api/response.rb
|
54
54
|
- lib/paypal-api/version.rb
|
55
|
+
- lib/paypal-api/webhook_verifier.rb
|
56
|
+
- lib/paypal-api/webhook_verifier/certs_cache.rb
|
55
57
|
- lib/paypal-rest-api.rb
|
56
58
|
homepage: https://github.com/aglushkov/paypal-api
|
57
59
|
licenses:
|
@@ -75,7 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
75
77
|
- !ruby/object:Gem::Version
|
76
78
|
version: '0'
|
77
79
|
requirements: []
|
78
|
-
rubygems_version: 3.5.
|
80
|
+
rubygems_version: 3.5.18
|
79
81
|
signing_key:
|
80
82
|
specification_version: 4
|
81
83
|
summary: PayPal REST API
|