paypal-rest-api 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bb3399d7f3412a84242a4ba9bf2c38447cc1987ca7a90a7e9a51a2628edb7809
4
+ data.tar.gz: ab029c43b63e930b30727a127b356c75388df84d8410fd45aac8edac2a37fa53
5
+ SHA512:
6
+ metadata.gz: b33f4638da4db6992030b9a5fcae5d8836e6cf50cf600adbec07518e8e182f79cb86802f271b8b7276d2391078e8ec11f4a1472865af8bc4a496bbd42f7cb290
7
+ data.tar.gz: 583f9a28508b386c282fd9d1b793042abe461425da3cc4227d17f033263e71b8c17ad2abff09c722f90f62d598f09fe5bbf84c0a9ce789cbf29e948d7c223957
data/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # PaypalAPI
2
+
3
+ ## Installation
4
+
5
+ ```bash
6
+ bundle add paypal-rest-api
7
+ ```
8
+
9
+ ## Features
10
+
11
+ - No dependencies;
12
+ - Automatic authorization & reauthorization;
13
+ - Auto-retries (configured);
14
+ - Automatically added Paypal-Request-Id header for idempotent requests if not
15
+ provided;
16
+
17
+ ## Usage
18
+
19
+ - All APIs accept `:query`, `:body` and `:headers` keyword parameters.
20
+ - Some APIs (like show, update, delete) require positional parameters with ID of
21
+ a resource.
22
+ - Response has `#body` method to get parsed JSON body.
23
+ This body has `symbolized` hash keys.
24
+ - Response contains methods to get original HTTP response.
25
+ - Failed request error (for non `2**` status codes) contains HTTP request and
26
+ response
27
+ - Failed request error (for network errors) contains request and original error
28
+
29
+ ```ruby
30
+ # Initiate client
31
+ client = PaypalAPI::Client.new(
32
+ client_id: ENV['PAYPAL_CLIENT_ID'],
33
+ client_secret: ENV['PAYPAL_CLIENT_SECRET'],
34
+ live: false
35
+ )
36
+
37
+ # APIs calls examples:
38
+ response = client.orders.create(body: body)
39
+ response = client.orders.show(order_id)
40
+ response = client.payments.capture(authorization_id, headers: headers)
41
+ response = client.webhooks.list(query: query)
42
+
43
+ # Client can be used directly to send request to any path
44
+ response = client.post(path, query: query, body: body, headers: headers)
45
+ response = client.get(path, query: query, body: body, headers: headers)
46
+ response = client.patch(path, query: query, body: body, headers: headers)
47
+ response = client.put(path, query: query, body: body, headers: headers)
48
+ response = client.delete(path, query: query, body: body, headers: headers)
49
+
50
+ # Getting response
51
+ response.body # parsed JSON. Parsed with `JSON.load(http_body, symbolyzed_keys: true)`
52
+ response[:foo] # Gets :foo attribute from parsed body
53
+ response.fetch(:foo) # Fetches :foo attribute from parsed body
54
+ response.http_response # original Net::HTTP::Response
55
+ response.http_body # original response string
56
+ response.http_status # Integer http status
57
+ response.http_headers # Hash with response headers (keys are strings)
58
+ response.requested_at # Time when request was sent
59
+ ```
60
+
61
+ Also PaypalAPI client can be added globally and class methods can be used instead:
62
+
63
+ ```ruby
64
+ # in config/initializers/paypal_api.rb
65
+ PaypalAPI.client = PaypalAPI::Client.new(...)
66
+
67
+ # in your business logic
68
+ response = PaypalAPI.orders.create(body: body)
69
+ response = PaypalAPI.webhooks.verify(body: body)
70
+
71
+ # same
72
+ PaypalAPI::Orders.create(body: body)
73
+ PaypalAPI::Webhooks.verify(body: body)
74
+
75
+ # Also now PaypalAPI class can be used as a client
76
+ response = PaypalAPI.post(path, query: query, body: body, headers: headers)
77
+ response = PaypalAPI.get(path, query: query, body: body, headers: headers)
78
+ response = PaypalAPI.patch(path, query: query, body: body, headers: headers)
79
+ response = PaypalAPI.put(path, query: query, body: body, headers: headers)
80
+ response = PaypalAPI.delete(path, query: query, body: body, headers: headers)
81
+ ```
82
+
83
+ ## Configuration options
84
+
85
+ PaypalAPI client accepts this additional options: `:live`, `:retries`, `:http_opts`
86
+
87
+ ### Option `:live`
88
+
89
+ PaypalAPI client can be defined with `live` option which is `false` by default.
90
+ When `live` is `false` all requests will be send to the sandbox endpoints.
91
+
92
+ ```ruby
93
+ client = PaypalAPI::Client.new(
94
+ live: true,
95
+ # ...
96
+ )
97
+ ```
98
+
99
+ ### Option `:retries`
100
+
101
+ This is a Hash with retries configuration.
102
+ By default retries are enabled, 3 retries with 0.25, 0.75, 1.5 seconds delay.
103
+ Default config: `{enabled: true, count: 3, sleep: [0.25, 0.75, 1.5]}`.
104
+ New options are merged with defaults.
105
+ Please keep `sleep` array same size as `count`.
106
+
107
+ Retries happen on any network error, on 409, 429, 5xx response status code.
108
+
109
+ ```ruby
110
+ client = PaypalAPI::Client.new(
111
+ retries: {count: 2, sleep: [0, 0]}
112
+ # ...
113
+ )
114
+ ```
115
+
116
+ ### Option `:http_opts`
117
+
118
+ This are the options that are provided to the `Net::HTTP.start` method,
119
+ like `:read_timeout`, `:write_timeout`, etc.
120
+
121
+ You can find full list of available options here <https://docs.ruby-lang.org/en/master/Net/HTTP.html#method-c-start>
122
+ (Please choose you version of ruby).
123
+
124
+ By default it is an empty hash.
125
+
126
+ ```ruby
127
+ client = PaypalAPI::Client.new(
128
+ http_opts: {read_timeout: 30, write_timeout: 30, open_timeout: 30}
129
+ # ...
130
+ )
131
+ ```
132
+
133
+ ## Errors
134
+
135
+ All APIs can raise error in case of network error or non-2xx response status code.
136
+
137
+ Errors structure:
138
+
139
+ - `PaypalAPI::Error`
140
+ - `PaypalAPI::NetworkError` - any network error
141
+ - `PaypalAPI::FailedRequest` - any non-2xx code error
142
+ - 400 - `PaypalAPI::BadRequestError`
143
+ - 401 - `PaypalAPI::UnauthorizedError`
144
+ - 403 - `PaypalAPI::ForbiddenError`
145
+ - 404 - `PaypalAPI::NotFoundError`
146
+ - 405 - `PaypalAPI::MethodNotAllowedError`
147
+ - 406 - `PaypalAPI::NotAcceptableError`
148
+ - 409 - `PaypalAPI::ConflictError`
149
+ - 415 - `PaypalAPI::UnsupportedMediaTypeError`
150
+ - 422 - `PaypalAPI::UnprocessableEntityError`
151
+ - 429 - `PaypalAPI::TooManyRequestsError`
152
+ - 5xx - `PaypalAPI::FatalError`
153
+ - 500 - `PaypalAPI::InternalServerError`
154
+ - 503 - `PaypalAPI::ServiceUnavailableError`
155
+
156
+ All errors have additional methods:
157
+
158
+ - `#response` - Original response object, can be nil in case of NetworkError
159
+ - `#request` - Original request object
160
+ - `#error_name` - Original error name
161
+ - `#error_message` - Original PayPal error `:message` or error `:description`
162
+ - `#error_debug_id` - Paypal debug_id found in response
163
+ - `#error_details` - Parsed PayPal error details found in parsed response
164
+ (with symbolized keys)
165
+
166
+ ```ruby
167
+ begin
168
+ response = PaypalAPI.payments.capture(authorization_id, body: body)
169
+ rescue PaypalAPI::Error => error
170
+ YourLogger.error(...)
171
+ end
172
+
173
+ ```
174
+
175
+ ## Development
176
+
177
+ ```bash
178
+ bundle install
179
+ rubocop
180
+ rspec
181
+ ```
182
+
183
+ ## Contributing
184
+
185
+ Bug reports and pull requests are welcome on GitHub at <https://github.com/aglushkov/paypal-api>.
186
+
187
+ ## License
188
+
189
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # AccessToken object stores authorization string and its expire time.
6
+ #
7
+ class AccessToken
8
+ attr_reader :requested_at, :expires_at, :authorization_string
9
+
10
+ def initialize(requested_at:, expires_in:, access_token:, token_type:)
11
+ @requested_at = requested_at
12
+ @expires_at = requested_at + expires_in
13
+ @authorization_string = "#{token_type} #{access_token}"
14
+ freeze
15
+ end
16
+
17
+ def expired?
18
+ Time.now >= expires_at
19
+ end
20
+
21
+ def inspect
22
+ "#<#{self.class.name} methods: (requested_at, expires_at, expired?, authorization_string)>"
23
+ end
24
+
25
+ alias_method :to_s, :inspect
26
+ end
27
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # PaypalAPI Client
6
+ #
7
+ class Client
8
+ attr_reader :config
9
+
10
+ def initialize(client_id:, client_secret:, live: nil, http_opts: nil, retries: nil)
11
+ @config = PaypalAPI::Config.new(
12
+ client_id: client_id,
13
+ client_secret: client_secret,
14
+ live: live,
15
+ http_opts: http_opts,
16
+ retries: retries
17
+ )
18
+
19
+ @access_token = nil
20
+ end
21
+
22
+ def access_token
23
+ (@access_token.nil? || @access_token.expired?) ? refresh_access_token : @access_token
24
+ end
25
+
26
+ def refresh_access_token
27
+ response = authorization.generate_access_token
28
+
29
+ @access_token = AccessToken.new(
30
+ requested_at: response.requested_at,
31
+ expires_in: response.fetch(:expires_in),
32
+ access_token: response.fetch(:access_token),
33
+ token_type: response.fetch(:token_type)
34
+ )
35
+ end
36
+
37
+ def post(path, query: nil, body: nil, headers: nil)
38
+ execute_request(Net::HTTP::Post, path, query: query, body: body, headers: headers)
39
+ end
40
+
41
+ def get(path, query: nil, body: nil, headers: nil)
42
+ execute_request(Net::HTTP::Get, path, query: query, body: body, headers: headers)
43
+ end
44
+
45
+ def patch(path, query: nil, body: nil, headers: nil)
46
+ execute_request(Net::HTTP::Patch, path, query: query, body: body, headers: headers)
47
+ end
48
+
49
+ def put(path, query: nil, body: nil, headers: nil)
50
+ execute_request(Net::HTTP::Put, path, query: query, body: body, headers: headers)
51
+ end
52
+
53
+ def delete(path, query: nil, body: nil, headers: nil)
54
+ execute_request(Net::HTTP::Delete, path, query: query, body: body, headers: headers)
55
+ end
56
+
57
+ def authorization
58
+ Authentication.new(self)
59
+ end
60
+
61
+ def orders
62
+ Orders.new(self)
63
+ end
64
+
65
+ def payments
66
+ Payments.new(self)
67
+ end
68
+
69
+ def webhooks
70
+ Webhooks.new(self)
71
+ end
72
+
73
+ private
74
+
75
+ def execute_request(http_method, path, query: nil, body: nil, headers: nil)
76
+ request = Request.new(self, http_method, path, query: query, body: body, headers: headers)
77
+ RequestExecutor.call(request)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # Base class for all PayPal API collections classes
6
+ #
7
+ class Collection
8
+ attr_reader :client
9
+
10
+ def initialize(client)
11
+ @client = client
12
+ end
13
+
14
+ def self.client
15
+ PaypalAPI.client
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # Authentication APIs collection
6
+ #
7
+ # @see https://developer.paypal.com/api/rest/authentication/
8
+ #
9
+ class Authentication < Collection
10
+ #
11
+ # Generate access-token API request path
12
+ #
13
+ PATH = "/v1/oauth2/token"
14
+
15
+ #
16
+ # Common class and instance methods
17
+ #
18
+ module APIs
19
+ #
20
+ # Generates access token.
21
+ #
22
+ # @see https://developer.paypal.com/api/rest/authentication/
23
+ #
24
+ # Default headers are:
25
+ # { "content-type" => "application/x-www-form-urlencoded", "authorization" => "Basic <TOKEN>" }
26
+ #
27
+ # Default body is:
28
+ # {grant_type: "client_credentials"}
29
+ #
30
+ # @example
31
+ # PaypalAPI::Authentication.generate_access_token
32
+ # PaypalAPI.client.authorization.generate_access_token # same
33
+ #
34
+ # @param query [Hash, nil] Request query string parameters
35
+ # @param body [Hash, nil] Request body parameters
36
+ # @param body [Hash, nil] Request headers
37
+ #
38
+ # @raise [Error] on network error or non 2** status code returned from PayPal
39
+ # @return [Response] detailed http request-response representation
40
+ #
41
+ def generate_access_token(query: nil, body: nil, headers: nil)
42
+ body ||= {grant_type: "client_credentials"}
43
+
44
+ default_headers = {
45
+ "content-type" => "application/x-www-form-urlencoded",
46
+ "authorization" => "Basic #{["#{client.config.client_id}:#{client.config.client_secret}"].pack("m0")}"
47
+ }
48
+
49
+ client.post(PATH, query: query, body: body, headers: merge_headers!(default_headers, headers))
50
+ end
51
+
52
+ private
53
+
54
+ def merge_headers!(headers, other_headers)
55
+ return headers unless other_headers
56
+
57
+ other_headers = other_headers.transform_keys { |key| key.to_s.downcase }
58
+ headers.merge!(other_headers)
59
+ end
60
+ end
61
+
62
+ include APIs
63
+ extend APIs
64
+ end
65
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # Create, update, retrieve, authorize, and capture orders.
6
+ #
7
+ # https://developer.paypal.com/docs/api/orders/v2/
8
+ #
9
+ class Orders < Collection
10
+ module APIs
11
+ #
12
+ # @see https://developer.paypal.com/docs/api/orders/v2/#orders_authorize
13
+ #
14
+ def authorize(id, query: nil, body: nil, headers: nil)
15
+ client.post("/v2/checkout/orders/#{id}/authorize", query: query, body: body, headers: headers)
16
+ end
17
+
18
+ #
19
+ # @see https://developer.paypal.com/docs/api/orders/v2/#orders_create
20
+ #
21
+ def create(query: nil, body: nil, headers: nil)
22
+ client.post("/v2/checkout/orders", query: query, body: body, headers: headers)
23
+ end
24
+
25
+ #
26
+ # @see https://developer.paypal.com/docs/api/orders/v2/#orders_get
27
+ #
28
+ def show(id, query: nil, body: nil, headers: nil)
29
+ client.get("/v2/checkout/orders/#{id}", query: query, body: body, headers: headers)
30
+ end
31
+ end
32
+
33
+ include APIs
34
+ extend APIs
35
+ end
36
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # Use in conjunction with the Orders API to authorize payments, capture authorized payments,
6
+ # refund payments that have already been captured, and show payment information.
7
+ #
8
+ # https://developer.paypal.com/docs/api/payments/v2
9
+ #
10
+ class Payments < Collection
11
+ module APIs
12
+ #
13
+ # @see https://developer.paypal.com/docs/api/payments/v2/#authorizations_capture
14
+ #
15
+ def capture(authorization_id, query: nil, body: nil, headers: nil)
16
+ client.post("/v2/payments/authorizations/#{authorization_id}/capture", query: query, body: body, headers: headers)
17
+ end
18
+
19
+ #
20
+ # @see https://developer.paypal.com/docs/api/payments/v2/#captures_refund
21
+ #
22
+ def refund(capture_id, query: nil, body: nil, headers: nil)
23
+ client.post("/v2/payments/captures/#{capture_id}/refund", query: query, body: body, headers: headers)
24
+ end
25
+
26
+ #
27
+ # @see https://developer.paypal.com/docs/api/payments/v2/#authorizations_get
28
+ #
29
+ def show_authorized(authorization_id, query: nil, body: nil, headers: nil)
30
+ client.get("/v2/payments/authorizations/#{authorization_id}", query: query, body: body, headers: headers)
31
+ end
32
+
33
+ #
34
+ # @see https://developer.paypal.com/docs/api/payments/v2/#captures_get
35
+ #
36
+ def show_captured(capture_id, query: nil, body: nil, headers: nil)
37
+ client.get("/v2/payments/captures/#{capture_id}", query: query, body: body, headers: headers)
38
+ end
39
+
40
+ #
41
+ # @see https://developer.paypal.com/docs/api/payments/v2/#authorizations_void
42
+ #
43
+ def void(authorization_id, query: nil, body: nil, headers: nil)
44
+ client.post("/v2/payments/authorizations/#{authorization_id}/void", query: query, body: body, headers: headers)
45
+ end
46
+ end
47
+
48
+ include APIs
49
+ extend APIs
50
+ end
51
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # https://developer.paypal.com/docs/api/webhooks/v1/
6
+ #
7
+ class Webhooks < Collection
8
+ module APIs
9
+ #
10
+ # https://developer.paypal.com/docs/api/webhooks/v1/#webhooks_post
11
+ #
12
+ def create(query: nil, body: nil, headers: nil)
13
+ client.post("/v1/notifications/webhooks", query: query, body: body, headers: headers)
14
+ end
15
+
16
+ #
17
+ # https://developer.paypal.com/docs/api/webhooks/v1/#webhooks_delete
18
+ #
19
+ def delete(webhook_id, query: nil, body: nil, headers: nil)
20
+ client.delete("/v1/notifications/webhooks/#{webhook_id}", query: query, body: body, headers: headers)
21
+ end
22
+
23
+ #
24
+ # https://developer.paypal.com/docs/api/webhooks/v1/#webhooks_list
25
+ #
26
+ def list(query: nil, body: nil, headers: nil)
27
+ client.get("/v1/notifications/webhooks", query: query, body: body, headers: headers)
28
+ end
29
+
30
+ #
31
+ # https://developer.paypal.com/docs/api/webhooks/v1/#webhooks_get
32
+ #
33
+ def show(webhook_id, query: nil, body: nil, headers: nil)
34
+ client.get("/v1/notifications/webhooks/#{webhook_id}", query: query, body: body, headers: headers)
35
+ end
36
+
37
+ #
38
+ # https://developer.paypal.com/docs/api/webhooks/v1/#webhooks_update
39
+ #
40
+ def update(webhook_id, query: nil, body: nil, headers: nil)
41
+ client.patch("/v1/notifications/webhooks/#{webhook_id}", query: query, body: body, headers: headers)
42
+ end
43
+
44
+ #
45
+ # https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post
46
+ #
47
+ def verify(query: nil, body: nil, headers: nil)
48
+ client.post("/v1/notifications/verify-webhook-signature", query: query, body: body, headers: headers)
49
+ end
50
+ end
51
+
52
+ include APIs
53
+ extend APIs
54
+ end
55
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ LIVE_URL = "https://api-m.paypal.com"
5
+ SANDBOX_URL = "https://api-m.sandbox.paypal.com"
6
+
7
+ DEFAULTS = {
8
+ live: false,
9
+ http_opts: {}.freeze,
10
+ retries: {enabled: true, count: 3, sleep: [0.25, 0.75, 1.5].freeze}.freeze
11
+ }.freeze
12
+
13
+ #
14
+ # Stores configuration for PaypalAPI Client
15
+ #
16
+ class Config
17
+ attr_reader :client_id, :client_secret, :live, :http_opts, :retries
18
+
19
+ def initialize(client_id:, client_secret:, live: nil, http_opts: nil, retries: nil)
20
+ @client_id = client_id
21
+ @client_secret = client_secret
22
+ @live = with_default(:live, live)
23
+ @http_opts = with_default(:http_opts, http_opts)
24
+ @retries = with_default(:retries, retries)
25
+ freeze
26
+ end
27
+
28
+ def url
29
+ live ? LIVE_URL : SANDBOX_URL
30
+ end
31
+
32
+ def inspect
33
+ "#<#{self.class.name} live: #{live}>"
34
+ end
35
+
36
+ alias_method :to_s, :inspect
37
+
38
+ private
39
+
40
+ def with_default(option_name, value)
41
+ default = DEFAULTS.fetch(option_name)
42
+
43
+ case value
44
+ when NilClass then default
45
+ when Hash then default.merge(value)
46
+ else value
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # Common interface for all errors
6
+ #
7
+ class Error < StandardError
8
+ attr_reader :response, :request, :error_name, :error_message, :error_debug_id, :error_details
9
+ end
10
+
11
+ #
12
+ # Raised when PayPal responds with any status code except 200, 201, 202, 204
13
+ #
14
+ class FailedRequest < Error
15
+ def initialize(message = nil, request:, response:)
16
+ super(message)
17
+ @request = request
18
+ @response = response
19
+
20
+ body = response.body
21
+ data = body.is_a?(Hash) ? body : {}
22
+ @error_name = data[:name] || data[:error] || response.http_response.class.name
23
+ @error_message = data[:message] || data[:error_description] || response.http_body.to_s
24
+ @error_debug_id = data[:debug_id]
25
+ @error_details = data[:details]
26
+ end
27
+ end
28
+
29
+ #
30
+ # Raised when a network raised when executing the request
31
+ # List of network errors can be found in errors/network_error_builder.rb
32
+ #
33
+ class NetworkError < Error
34
+ def initialize(message = nil, request:, error:)
35
+ super(message)
36
+ @request = request
37
+ @response = nil
38
+ @error_name = error.class.name
39
+ @error_message = error.message
40
+ @error_debug_id = nil
41
+ @error_details = nil
42
+ end
43
+ end
44
+
45
+ # 400
46
+ class BadRequestError < FailedRequest
47
+ end
48
+
49
+ # 401
50
+ class UnauthorizedError < FailedRequest
51
+ end
52
+
53
+ # 403
54
+ class ForbiddenError < FailedRequest
55
+ end
56
+
57
+ # 404
58
+ class NotFoundError < FailedRequest
59
+ end
60
+
61
+ # 405
62
+ class MethodNotAllowedError < FailedRequest
63
+ end
64
+
65
+ # 406
66
+ class NotAcceptableError < FailedRequest
67
+ end
68
+
69
+ # 409
70
+ class ConflictError < FailedRequest
71
+ end
72
+
73
+ # 415
74
+ class UnsupportedMediaTypeError < FailedRequest
75
+ end
76
+
77
+ # 422
78
+ class UnprocessableEntityError < FailedRequest
79
+ end
80
+
81
+ # 429
82
+ class TooManyRequestsError < FailedRequest
83
+ end
84
+
85
+ # 5xx
86
+ class FatalError < FailedRequest
87
+ end
88
+
89
+ # 500
90
+ class InternalServerError < FatalError
91
+ end
92
+
93
+ # 503
94
+ class ServiceUnavailableError < FatalError
95
+ end
96
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module PaypalAPI
6
+ #
7
+ # Builds PaypalAPI::FailedRequest error
8
+ #
9
+ class FailedRequestErrorBuilder
10
+ RESPONSE_ERROR_MAP = {
11
+ Net::HTTPBadRequest => BadRequestError, # 400
12
+ Net::HTTPUnauthorized => UnauthorizedError, # 401
13
+ Net::HTTPForbidden => ForbiddenError, # 403
14
+ Net::HTTPNotFound => NotFoundError, # 404
15
+ Net::HTTPMethodNotAllowed => MethodNotAllowedError, # 405
16
+ Net::HTTPNotAcceptable => NotAcceptableError, # 406
17
+ Net::HTTPConflict => ConflictError, # 409
18
+ Net::HTTPUnsupportedMediaType => UnsupportedMediaTypeError, # 415
19
+ Net::HTTPUnprocessableEntity => UnprocessableEntityError, # 422
20
+ Net::HTTPTooManyRequests => TooManyRequestsError, # 429
21
+ Net::HTTPInternalServerError => InternalServerError, # 500
22
+ Net::HTTPServiceUnavailable => ServiceUnavailableError # 503
23
+ }.freeze
24
+
25
+ class << self
26
+ def call(request:, response:)
27
+ http_response = response.http_response
28
+ error_message = "#{http_response.code} #{http_response.message}"
29
+ error_class = RESPONSE_ERROR_MAP.fetch(http_response.class, FailedRequest)
30
+ error_class.new(error_message, response: response, request: request)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # Builds PaypalAPI::NetowrkError error
6
+ #
7
+ class NetworkErrorBuilder
8
+ ERRORS = [
9
+ EOFError,
10
+ Errno::ECONNABORTED,
11
+ Errno::ECONNREFUSED,
12
+ Errno::ECONNRESET,
13
+ Errno::EHOSTUNREACH,
14
+ Errno::EPIPE,
15
+ Errno::ETIMEDOUT,
16
+ IOError,
17
+ OpenSSL::SSL::SSLError,
18
+ SocketError,
19
+ Timeout::Error # Net::OpenTimeout, Net::ReadTimeout
20
+ ].freeze
21
+
22
+ class << self
23
+ def call(request:, error:)
24
+ NetworkError.new(error.message, request: request, error: error)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module PaypalAPI
7
+ #
8
+ # Builds PaypalAPI::Request:
9
+ # - assigns query params
10
+ # - assigns body params
11
+ # - assigns Authentication header
12
+ # - assigns paypal-request-id header
13
+ # - assigns content-type header
14
+ #
15
+ class Request
16
+ attr_reader :client, :http_request
17
+ attr_accessor :requested_at
18
+
19
+ # rubocop:disable Metrics/ParameterLists
20
+ def initialize(client, request_type, path, body: nil, query: nil, headers: nil)
21
+ @client = client
22
+ @http_request = build_http_request(request_type, path, body: body, query: query, headers: headers)
23
+ @requested_at = nil
24
+ end
25
+ # rubocop:enable Metrics/ParameterLists
26
+
27
+ private
28
+
29
+ def build_http_request(request_type, path, body:, query:, headers:)
30
+ uri = request_uri(path, query)
31
+ http_request = request_type.new(uri)
32
+
33
+ add_headers(http_request, headers || {})
34
+ add_body(http_request, body)
35
+
36
+ http_request
37
+ end
38
+
39
+ def add_headers(http_request, headers)
40
+ headers.each { |key, value| http_request[key] = value }
41
+
42
+ http_request["content-type"] ||= "application/json"
43
+ http_request["authorization"] ||= client.access_token.authorization_string
44
+ http_request["paypal-request-id"] ||= SecureRandom.uuid if idempotent?(http_request)
45
+ end
46
+
47
+ def add_body(http_request, body)
48
+ return unless body
49
+
50
+ json?(http_request) ? http_request.body = JSON.dump(body) : http_request.set_form_data(body)
51
+ end
52
+
53
+ def request_uri(path, query)
54
+ uri = URI.join(client.config.url, path)
55
+ uri.query = URI.encode_www_form(query) if query && !query.empty?
56
+ uri
57
+ end
58
+
59
+ def idempotent?(http_request)
60
+ http_request.method != Net::HTTP::Get::METHOD
61
+ end
62
+
63
+ def json?(http_request)
64
+ http_request["content-type"].include?("json")
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ #
5
+ # Executes PaypalAPI::Request and returns PaypalAPI::Response or raises PaypalAPI::Error
6
+ #
7
+ class RequestExecutor
8
+ RETRYABLE_RESPONSES = [
9
+ Net::HTTPServerError, # 5xx
10
+ Net::HTTPConflict, # 409
11
+ Net::HTTPTooManyRequests # 429
12
+ ].freeze
13
+
14
+ class << self
15
+ def call(request)
16
+ http_response = execute(request)
17
+ response = Response.new(http_response, requested_at: request.requested_at)
18
+ raise FailedRequestErrorBuilder.call(request: request, response: response) unless http_response.is_a?(Net::HTTPSuccess)
19
+
20
+ response
21
+ end
22
+
23
+ private
24
+
25
+ def execute(request, retry_number: 0)
26
+ http_response = execute_http_request(request)
27
+ rescue *NetworkErrorBuilder::ERRORS => error
28
+ retry_on_network_error(request, error, retry_number)
29
+ else
30
+ retryable?(request, http_response, retry_number) ? retry_request(request, retry_number) : http_response
31
+ end
32
+
33
+ def execute_http_request(request)
34
+ http_request = request.http_request
35
+ http_opts = request.client.config.http_opts
36
+ uri = http_request.uri
37
+ request.requested_at = Time.now
38
+
39
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, **http_opts) do |http|
40
+ http.max_retries = 0 # we have custom retries logic
41
+ http.request(http_request)
42
+ end
43
+ end
44
+
45
+ def retry_on_network_error(request, error, retry_number)
46
+ raise NetworkErrorBuilder.call(request: request, error: error) if retries_limit_reached?(request, retry_number)
47
+
48
+ retry_request(request, retry_number)
49
+ end
50
+
51
+ def retry_request(request, current_retry_number)
52
+ sleep(retry_sleep_seconds(request, current_retry_number))
53
+ execute(request, retry_number: current_retry_number + 1)
54
+ end
55
+
56
+ def retries_limit_reached?(request, retry_number)
57
+ retry_number >= request.client.config.retries[:count]
58
+ end
59
+
60
+ def retry_sleep_seconds(request, current_retry_number)
61
+ seconds_per_retry = request.client.config.retries[:sleep]
62
+ seconds_per_retry[current_retry_number] || seconds_per_retry.last || 1
63
+ end
64
+
65
+ def retryable?(request, http_response, retry_number)
66
+ !http_response.is_a?(Net::HTTPSuccess) &&
67
+ !retries_limit_reached?(request, retry_number) &&
68
+ retryable_request?(request, http_response)
69
+ end
70
+
71
+ def retryable_request?(request, http_response)
72
+ return true if RETRYABLE_RESPONSES.any? { |retryable_class| http_response.is_a?(retryable_class) }
73
+
74
+ retry_unauthorized?(request, http_response)
75
+ end
76
+
77
+ def retry_unauthorized?(request, http_response)
78
+ return false unless http_response.is_a?(Net::HTTPUnauthorized) # 401
79
+ return false if http_response.uri.path == Authentication::PATH # it's already an Authentication request
80
+
81
+ # set new access-token
82
+ request.http_request["authorization"] = request.client.refresh_access_token.authorization_string
83
+ true
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PaypalAPI
6
+ #
7
+ # PaypalAPI::Response object
8
+ #
9
+ class Response
10
+ attr_reader :http_response, :requested_at
11
+
12
+ def initialize(http_response, requested_at:)
13
+ @requested_at = requested_at
14
+ @http_response = http_response
15
+ @http_status = nil
16
+ @http_headers = nil
17
+ @http_body = nil
18
+ @body = nil
19
+ end
20
+
21
+ def body
22
+ @body ||= json_response? ? parse_json(http_body) : http_body
23
+ end
24
+
25
+ def http_status
26
+ @http_status ||= http_response.code.to_i
27
+ end
28
+
29
+ def http_headers
30
+ @http_headers ||= http_response.each_header.to_h
31
+ end
32
+
33
+ def http_body
34
+ @http_body ||= http_response.body
35
+ end
36
+
37
+ def [](key)
38
+ body[key.to_sym] if body.is_a?(Hash)
39
+ end
40
+
41
+ def fetch(key)
42
+ data = body.is_a?(Hash) ? body : {}
43
+ data.fetch(key.to_sym)
44
+ end
45
+
46
+ def inspect
47
+ "#<#{self.class.name} (#{http_response.code})>"
48
+ end
49
+
50
+ private
51
+
52
+ def json_response?
53
+ content_type = http_response["content-type"]
54
+ !content_type.nil? && content_type.include?("json")
55
+ end
56
+
57
+ def parse_json(json)
58
+ JSON.parse(json, symbolize_names: true)
59
+ rescue JSON::ParserError, TypeError
60
+ json
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaypalAPI
4
+ # PaypalAPI gem version
5
+ #
6
+ # @return [String] SemVer gem version
7
+ #
8
+ VERSION = File.read(File.join(File.dirname(__FILE__), "../../VERSION")).strip
9
+ end
data/lib/paypal-api.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # PaypalAPI is a main gem module.
5
+ # It can store global PaypalAPI::Client for easier access to APIs.
6
+ #
7
+ # For example:
8
+ # # setup client in an initializer
9
+ # PaypalAPI.client = PaypalAPI::Client.new(...)
10
+ #
11
+ # # And then use anywhere
12
+ # PaypalAPI::Webhooks.list # or PaypalAPI.webhooks.list
13
+ #
14
+ module PaypalAPI
15
+ class << self
16
+ attr_writer :client
17
+
18
+ [:post, :get, :patch, :put, :delete].each do |method_name|
19
+ define_method(method_name) do |path, query: nil, body: nil, headers: nil|
20
+ client.public_send(method_name, path, query: query, body: body, headers: headers)
21
+ end
22
+ end
23
+
24
+ %i[
25
+ authorization
26
+ orders
27
+ payments
28
+ webhooks
29
+ ].each do |method_name|
30
+ define_method(method_name) do
31
+ client.public_send(method_name)
32
+ end
33
+ end
34
+
35
+ def client
36
+ raise "#{name}.client must be set" unless @client
37
+
38
+ @client
39
+ end
40
+ end
41
+ end
42
+
43
+ require_relative "paypal-api/access_token"
44
+ require_relative "paypal-api/client"
45
+ require_relative "paypal-api/collection"
46
+ require_relative "paypal-api/config"
47
+ require_relative "paypal-api/error"
48
+ require_relative "paypal-api/failed_request_error_builder"
49
+ require_relative "paypal-api/network_error_builder"
50
+ require_relative "paypal-api/request"
51
+ require_relative "paypal-api/request_executor"
52
+ require_relative "paypal-api/response"
53
+ require_relative "paypal-api/collections/authentication"
54
+ require_relative "paypal-api/collections/orders"
55
+ require_relative "paypal-api/collections/payments"
56
+ require_relative "paypal-api/collections/webhooks"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "paypal-api"
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: paypal-rest-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Andrey Glushkov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-08-06 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: PayPal REST API with no dependencies.
14
+ email:
15
+ - aglushkov@shakuro.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - VERSION
22
+ - lib/paypal-api.rb
23
+ - lib/paypal-api/access_token.rb
24
+ - lib/paypal-api/client.rb
25
+ - lib/paypal-api/collection.rb
26
+ - lib/paypal-api/collections/authentication.rb
27
+ - lib/paypal-api/collections/orders.rb
28
+ - lib/paypal-api/collections/payments.rb
29
+ - lib/paypal-api/collections/webhooks.rb
30
+ - lib/paypal-api/config.rb
31
+ - lib/paypal-api/error.rb
32
+ - lib/paypal-api/failed_request_error_builder.rb
33
+ - lib/paypal-api/network_error_builder.rb
34
+ - lib/paypal-api/request.rb
35
+ - lib/paypal-api/request_executor.rb
36
+ - lib/paypal-api/response.rb
37
+ - lib/paypal-api/version.rb
38
+ - lib/paypal-rest-api.rb
39
+ homepage: https://github.com/aglushkov/paypal-api
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ source_code_uri: https://github.com/aglushkov/paypal-api
44
+ documentation_uri: https://www.rubydoc.info/gems/serega
45
+ changelog_uri: https://github.com/aglushkov/paypal-api/blob/master/CHANGELOG.md
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 2.6.0
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.5.17
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: PayPal REST API
65
+ test_files: []