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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8570bf045fe3986e03358401550d77f16dc5667c9da1c3e233fd07b95231c3e
4
- data.tar.gz: e41098ed451a8997eff6bc0720f0c957c68fd843f8d2bee7b44cbd794c7913f0
3
+ metadata.gz: 161078b6b9ff7513fe6634bc4e37c181374858dec0cac4261d5cc662d115787c
4
+ data.tar.gz: 60346804bbd8a930f695a922a6ce07adaefca26e25f7777e70de0d9f5d87a01a
5
5
  SHA512:
6
- metadata.gz: 193437cd7bf07228015eaead34a84952c14e02e3eca154df7df6df1eabcd28a26c4cc99a92ee16df03810b7c1c6824a4605fadf8e3008cf98053d1fa6cd1c883
7
- data.tar.gz: dd2098b7c52ba4b8d1fc41a2152d7848168b7c831a2705acdfcacb5aab9ea3c80194401a2d883f7853fd8614cd4079c49788da0c6e7cfd35c614a7d41ff27f8c
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 HASH.
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.requested_at # Time when request was sent
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.4
1
+ 0.1.0
@@ -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: response.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.call(request)
283
+ RequestExecutor.new(self, request).call
221
284
  end
222
285
  end
223
286
  end
@@ -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
 
@@ -19,8 +19,8 @@ module PaypalAPI
19
19
  # @return [Net::HTTPRequest] Generated Net::HTTPRequest
20
20
  attr_reader :http_request
21
21
 
22
- # @return [Time, nil] Time when request was sent
23
- attr_accessor :requested_at
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
- @requested_at = nil
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 = request_uri(path, query)
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
- add_headers(http_request, headers || {})
54
- add_body(http_request, body)
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 add_headers(http_request, headers)
60
- headers.each { |key, value| http_request[key] = value }
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
- http_request["content-type"] ||= "application/json"
63
- http_request["authorization"] ||= client.access_token.authorization_string
64
- http_request["paypal-request-id"] ||= SecureRandom.uuid if idempotent?(http_request)
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 add_body(http_request, body)
102
+ def build_http_body(http_request, body)
68
103
  return unless body
69
104
 
70
- json?(http_request) ? http_request.body = JSON.dump(body) : http_request.set_form_data(body)
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 request_uri(path, query)
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 idempotent?(http_request)
80
- http_request.method != Net::HTTP::Get::METHOD
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
- # List of Net::HTTP responses that must be retried
9
- RETRYABLE_RESPONSES = [
10
- Net::HTTPServerError, # 5xx
11
- Net::HTTPConflict, # 409
12
- Net::HTTPTooManyRequests # 429
13
- ].freeze
14
-
15
- class << self
16
- #
17
- # Executes prepared Request, handles retries and preparation of errors
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
- response
29
- end
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
- private
33
+ def execute_request(retry_number: 0)
34
+ callbacks_context[:retry_number] = retry_number
32
35
 
33
- def execute(request, retry_number: 0)
34
- http_response = execute_http_request(request)
35
- rescue *NetworkErrorBuilder::ERRORS => error
36
- retry_on_network_error(request, error, retry_number)
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
- retryable?(request, http_response, retry_number) ? retry_request(request, retry_number) : http_response
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
- def execute_http_request(request)
42
- http_request = request.http_request
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
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, **http_opts) do |http|
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
- def retry_on_network_error(request, error, retry_number)
54
- raise NetworkErrorBuilder.call(request: request, error: error) if retries_limit_reached?(request, retry_number)
67
+ Response.new(http_response, request: request)
68
+ end
55
69
 
56
- retry_request(request, retry_number)
57
- end
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
- def retry_request(request, current_retry_number)
60
- sleep(retry_sleep_seconds(request, current_retry_number))
61
- execute(request, retry_number: current_retry_number + 1)
62
- end
76
+ def retries_limit_reached?(retry_number)
77
+ retry_number >= retries[:count]
78
+ end
63
79
 
64
- def retries_limit_reached?(request, retry_number)
65
- retry_number >= request.client.config.retries[:count]
66
- end
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
- def retry_sleep_seconds(request, current_retry_number)
69
- seconds_per_retry = request.client.config.retries[:sleep]
70
- seconds_per_retry[current_retry_number] || seconds_per_retry.last || 1
71
- end
85
+ def retryable?(response, retry_number)
86
+ response.failed? &&
87
+ !retries_limit_reached?(retry_number) &&
88
+ retryable_request?(response)
89
+ end
72
90
 
73
- def retryable?(request, http_response, retry_number)
74
- !http_response.is_a?(Net::HTTPSuccess) &&
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
- def retryable_request?(request, http_response)
80
- return true if RETRYABLE_RESPONSES.any? { |retryable_class| http_response.is_a?(retryable_class) }
94
+ retry_unauthorized?(response)
95
+ end
81
96
 
82
- retry_unauthorized?(request, http_response)
83
- end
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
- def retry_unauthorized?(request, http_response)
86
- return false unless http_response.is_a?(Net::HTTPUnauthorized) # 401
87
- return false if http_response.uri.path == Authentication::PATH # it's already an Authentication request
101
+ # set new access-token
102
+ request.http_request["authorization"] = client.refresh_access_token.authorization_string
103
+ true
104
+ end
88
105
 
89
- # set new access-token
90
- request.http_request["authorization"] = request.client.refresh_access_token.authorization_string
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
@@ -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 [Time] Time when request was sent
14
- attr_reader :requested_at
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 requested_at [Time] Time when original response was requested
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, requested_at:)
25
- @requested_at = requested_at
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
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-25 00:00:00.000000000 Z
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.17
80
+ rubygems_version: 3.5.18
79
81
  signing_key:
80
82
  specification_version: 4
81
83
  summary: PayPal REST API