easyship 0.2.0 → 0.4.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: 7c050cd7909443866f9f0365555f577024aa49ce0065be2a961018385409972d
4
- data.tar.gz: a827b2cdaa8207b8723f5a7c2b65efba7c6b7f9e10cf16b46ea36f34aebae763
3
+ metadata.gz: 7b584f647a6c1e9cd0379a94b3fc479c8f82691ab1ff8a36d797dae1c24e9c89
4
+ data.tar.gz: 5f288ce157f0b0b1c5e19f6c8a0a7851bf2bf1c5acb1c22b6660656ceed10204
5
5
  SHA512:
6
- metadata.gz: 959f2149e8e0d3f33ce0592de28df9718a0fb1c24cdb78909acffbdcecbeab71449b29a1000c03589d24616603214f0f9aa0dc05bad67e96ee413b2acad2a25c
7
- data.tar.gz: 0ba62b8bf714db84417d4c2abb13e66738b0510dd0f57997c9403cdbfb81e2fa78029fb9b058d07a5ed943bb5eb8a9054be8929f6a7975b816fc59dda360b781
6
+ metadata.gz: 22a0cbabb4582b714d56827fdd6f29bee92958900b2204ba3fce15a62ed85139e327dcfaa39dcf3f935cdeab0440424fa00bf044f003282ac9212837c97a83a2
7
+ data.tar.gz: fd30c1f4d75e89f2cc01ea8b97040cd19717af6f5e7d6bdb26a661507de197882be0f10f9cfaae2f49ed92d65e8aa5c8cd9f6ee3b24e4ef747da158012a94abc
data/.rubocop.yml CHANGED
@@ -12,3 +12,6 @@ Style/Copyright:
12
12
 
13
13
  RSpec/ExampleLength:
14
14
  Max: 10
15
+
16
+ RSpec/NestedGroups:
17
+ Max: 5
data/CHANGELOG.md CHANGED
@@ -1,6 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [v.0.2.0](https://github.com/mmarusyk/easyship/tree/v0.2.0) - 2025-05-04
3
+ ## [v0.4.0](https://github.com/mmarusyk/easyship/tree/v0.4.0) - 2026-01-24
4
+
5
+ ### Added
6
+ - Support for custom headers by @mmarusyk in https://github.com/mmarusyk/easyship/pull/14
7
+
8
+ ## [v0.3.0](https://github.com/mmarusyk/easyship/tree/v0.3.0) - 2025-11-29
9
+
10
+ ### Added
11
+ - Rate limiting for cursor by @mmarusyk in https://github.com/mmarusyk/easyship/pull/13
12
+
13
+
14
+ ## [v0.2.0](https://github.com/mmarusyk/easyship/tree/v0.2.0) - 2025-05-04
4
15
 
5
16
  ### Added
6
17
  - Add response_body and response_header to Easyship::Error
data/README.md CHANGED
@@ -58,10 +58,10 @@ Easyship.configure do |config|
58
58
  end
59
59
  ```
60
60
 
61
- Configuration supports the next keys: `url`, `api_key`, `per_page`.
61
+ Configuration supports the next keys: `url`, `api_key`, `per_page`, `requests_per_second`, `requests_per_minute`, `headers`.
62
62
 
63
63
  ### Making Requests
64
- `Easyship::Client` supports the next methods: `get`, `post`, `put`, `delete`.
64
+ `Easyship::Client` supports the next methods: `get`, `post`, `put`, `patch`, `delete`.
65
65
  ```ruby
66
66
  Easyship::Client.instance.get('/2023-01/account')
67
67
  ```
@@ -131,7 +131,146 @@ end
131
131
  shipments # Returns all shipments from all pages
132
132
  ```
133
133
 
134
- To setup items perpage, use the key `per_page` in your configuration.
134
+ To setup items per page, use the key `per_page` in your configuration.
135
+
136
+ For Example:
137
+
138
+ ```ruby
139
+ # Global defaults
140
+ Easyship.configure do |config|
141
+ config.per_page = 100
142
+ end
143
+
144
+ # Per-call overrides (any of these are supported)
145
+ shipments = []
146
+ Easyship::Client.instance.get('/2023-01/shipments', {
147
+ per_page: 50,
148
+ }) do |page|
149
+ shipments.concat(page[:shipments])
150
+ end
151
+ ```
152
+
153
+ Rate limiting during pagination:
154
+ - The cursor has no default rate limiting; it uses nil to indicate that rate limiting is disabled.
155
+ - Use [Rate limiting documentation](https://developers.easyship.com/reference/rate-limit) for more details which values to set.
156
+ - You can override the limits per call, by passing `requests_per_second` and `requests_per_minute`.
157
+
158
+ Examples:
159
+
160
+ ```ruby
161
+ # Global defaults
162
+ Easyship.configure do |config|
163
+ config.requests_per_second = 10
164
+ config.requests_per_minute = 60
165
+ end
166
+
167
+ # Per-call overrides (any of these are supported)
168
+ shipments = []
169
+ Easyship::Client.instance.get('/2023-01/shipments', {
170
+ requests_per_second: 5,
171
+ requests_per_minute: 40,
172
+ }) do |page|
173
+ shipments.concat(page[:shipments])
174
+ end
175
+ ```
176
+
177
+ ### Custom Headers
178
+
179
+ You can pass custom headers both globally (via configuration) and per-request.
180
+
181
+ **Global Headers (via Configuration):**
182
+
183
+ ```ruby
184
+ Easyship.configure do |config|
185
+ config.url = 'api_url'
186
+ config.api_key = 'your_easyship_api_key'
187
+ config.headers = {
188
+ 'X-Custom-Header' => 'custom-value'
189
+ }
190
+ end
191
+ ```
192
+
193
+ **Per-Request Headers:**
194
+
195
+ ```ruby
196
+ # Override or add headers for a specific request
197
+ Easyship::Client.instance.get('/2023-01/account', {}, headers: {
198
+ 'X-Request-ID' => 'unique-request-id'
199
+ })
200
+
201
+ # POST with custom headers
202
+ Easyship::Client.instance.post('/2023-01/shipment', payload, headers: {
203
+ 'X-Idempotency-Key' => 'unique-key'
204
+ })
205
+
206
+ # PUT with custom headers
207
+ Easyship::Client.instance.put('/2023-01/shipment/123', payload, headers: {
208
+ 'X-Custom-Header' => 'value'
209
+ })
210
+
211
+ # PATCH with custom headers
212
+ Easyship::Client.instance.patch('/2023-01/shipment/123', payload, headers: {
213
+ 'X-Custom-Header' => 'value'
214
+ })
215
+
216
+ # DELETE with custom headers
217
+ Easyship::Client.instance.delete('/2023-01/shipment/123', {}, headers: {
218
+ 'X-Custom-Header' => 'value'
219
+ })
220
+ ```
221
+
222
+ **Note:** Per-request headers will override global headers if the same header key is used.
223
+
224
+ ### Using Idempotency Keys
225
+
226
+ Idempotency keys are essential for safely retrying requests without accidentally performing the same operation twice. This is particularly important for POST requests that create resources (like shipments, orders, or payments).
227
+
228
+ **Example - Creating a Shipment with Idempotency:**
229
+
230
+ ```ruby
231
+ # Generate a unique key (e.g., UUID, database record ID, or any unique identifier)
232
+ idempotency_key = SecureRandom.uuid
233
+
234
+ payload = {
235
+ origin_country_alpha2: "SG",
236
+ destination_country_alpha2: "US",
237
+ tax_paid_by: "Recipient",
238
+ is_insured: false,
239
+ items: [
240
+ {
241
+ description: "Product",
242
+ actual_weight: 1.2,
243
+ height: 10,
244
+ width: 15,
245
+ length: 20,
246
+ declared_currency: "USD",
247
+ declared_customs_value: 50
248
+ }
249
+ ]
250
+ }
251
+
252
+ begin
253
+ response = Easyship::Client.instance.post(
254
+ '/2023-01/shipments',
255
+ payload,
256
+ headers: { 'X-Idempotency-Key' => idempotency_key }
257
+ )
258
+
259
+ # If this request fails due to network issues and you retry with the same key,
260
+ # the API will return the same shipment without creating a duplicate
261
+ rescue Easyship::Errors::EasyshipError => e
262
+ # Safe to retry with the same idempotency_key
263
+ retry
264
+ end
265
+ ```
266
+
267
+ **Best Practices:**
268
+
269
+ - Use unique, random keys (UUIDs are recommended)
270
+ - Store the idempotency key with your order/shipment record in your database
271
+ - Reuse the same key when retrying a failed request
272
+ - Don't reuse keys across different operations or resources
273
+ - Keys typically expire after 24 hours (check API documentation)
135
274
 
136
275
  ## Development
137
276
 
@@ -16,46 +16,65 @@ module Easyship
16
16
  @api_key = Easyship.configuration.api_key
17
17
  end
18
18
 
19
- def get(path, params = {}, &block)
19
+ def get(path, params = {}, headers: {}, &block)
20
20
  if block
21
21
  Easyship::Pagination::Cursor.new(self, path, params).all(&block)
22
22
  else
23
- response = connection.get(path, params)
23
+ response = connection(headers).get(path, params)
24
24
 
25
25
  handle_response(response)
26
26
  end
27
27
  end
28
28
 
29
- def post(path, params = {})
30
- response = connection.post(path, params.to_json)
29
+ def post(path, params = {}, headers: {})
30
+ response = connection(headers).post(path, params.to_json)
31
31
 
32
32
  handle_response(response)
33
33
  end
34
34
 
35
- def put(path, params = {})
36
- response = connection.put(path, params.to_json)
35
+ def put(path, params = {}, headers: {})
36
+ response = connection(headers).put(path, params.to_json)
37
37
 
38
38
  handle_response(response)
39
39
  end
40
40
 
41
- def delete(path, params = {})
42
- response = connection.delete(path, params)
41
+ def delete(path, params = {}, headers: {})
42
+ response = connection(headers).delete(path, params)
43
+
44
+ handle_response(response)
45
+ end
46
+
47
+ def patch(path, params = {}, headers: {})
48
+ response = connection(headers).patch(path, params.to_json)
43
49
 
44
50
  handle_response(response)
45
51
  end
46
52
 
47
53
  private
48
54
 
49
- def connection
55
+ def connection(custom_headers = {})
50
56
  Faraday.new(url: @url) do |faraday|
51
57
  faraday.request :url_encoded
52
58
  faraday.adapter Faraday.default_adapter
53
59
  faraday.headers['Authorization'] = "Bearer #{@api_key}"
54
60
  faraday.headers['Content-Type'] = 'application/json'
61
+ merge_headers(faraday, custom_headers)
55
62
  faraday.use Easyship::Middleware::ErrorHandlerMiddleware
56
63
  end
57
64
  end
58
65
 
66
+ def merge_headers(faraday, custom_headers)
67
+ # Merge global configuration headers
68
+ Easyship.configuration.headers.each do |key, value|
69
+ faraday.headers[key] = value
70
+ end
71
+
72
+ # Merge request-specific headers (override globals)
73
+ custom_headers.each do |key, value|
74
+ faraday.headers[key] = value
75
+ end
76
+ end
77
+
59
78
  def handle_response(response)
60
79
  Easyship::Handlers::ResponseBodyHandler.handle_response(response)
61
80
  end
@@ -3,12 +3,15 @@
3
3
  module Easyship
4
4
  # Represents the configuration settings for the Easyship client.
5
5
  class Configuration
6
- attr_accessor :url, :api_key, :per_page
6
+ attr_accessor :url, :api_key, :per_page, :requests_per_second, :requests_per_minute, :headers
7
7
 
8
8
  def initialize
9
9
  @url = nil
10
10
  @api_key = nil
11
11
  @per_page = 100 # Maximum possible number of items per page
12
+ @requests_per_second = nil
13
+ @requests_per_minute = nil
14
+ @headers = {}
12
15
  end
13
16
  end
14
17
  end
@@ -4,20 +4,26 @@ module Easyship
4
4
  module Pagination
5
5
  # Represents a pagination object
6
6
  class Cursor
7
- attr_reader :client, :path, :params, :key, :per_page
7
+ CONFIGURATION_VARIABLES = %i[requests_per_second requests_per_minute].freeze
8
+
9
+ attr_reader :client, :path, :params, :key, :per_page, :requests_per_second, :requests_per_minute
8
10
 
9
11
  def initialize(client, path, params)
10
12
  @client = client
11
13
  @path = path
12
14
  @params = params
13
15
  @per_page = params[:per_page] || Easyship.configuration.per_page
16
+ @requests_per_second = params[:requests_per_second] || Easyship.configuration.requests_per_second
17
+ @requests_per_minute = params[:requests_per_minute] || Easyship.configuration.requests_per_minute
14
18
  end
15
19
 
16
20
  def all
17
21
  page = 1
18
22
 
19
23
  loop do
20
- body = client.get(path, params.merge(page: page, per_page: per_page))
24
+ limiter.throttle!
25
+
26
+ body = client.get(path, build_request_params(page: page))
21
27
 
22
28
  break if body.nil? || body.empty?
23
29
 
@@ -28,6 +34,19 @@ module Easyship
28
34
  page += 1
29
35
  end
30
36
  end
37
+
38
+ private
39
+
40
+ def build_request_params(page:)
41
+ params.merge(page: page, per_page: per_page).except(*CONFIGURATION_VARIABLES)
42
+ end
43
+
44
+ def limiter
45
+ @limiter ||= Easyship::RateLimiting::WindowRateLimiter.new(
46
+ requests_per_second: requests_per_second,
47
+ requests_per_minute: requests_per_minute
48
+ )
49
+ end
31
50
  end
32
51
  end
33
52
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyship
4
+ module RateLimiting
5
+ # Represents RateLimiter
6
+ class RateLimiter
7
+ def throttle!
8
+ raise NotImplementedError
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyship
4
+ module RateLimiting
5
+ # Represents WindowRateLimiter
6
+ class WindowRateLimiter < RateLimiter
7
+ attr_reader :timestamps, :requests_per_second, :requests_per_minute
8
+
9
+ def initialize(requests_per_second:, requests_per_minute:)
10
+ super()
11
+ @requests_per_second = requests_per_second
12
+ @requests_per_minute = requests_per_minute
13
+ @timestamps = []
14
+ end
15
+
16
+ def throttle!
17
+ now = Time.now
18
+
19
+ timestamps << now
20
+
21
+ # Remove timestamps older than a minute
22
+ timestamps.reject! { |t| t < now - 60 }
23
+
24
+ check_second_window(now)
25
+ check_minute_window(now)
26
+ end
27
+
28
+ private
29
+
30
+ def check_second_window(now)
31
+ second_requests = timestamps.count { |t| t > now - 1 }
32
+
33
+ return if !requests_per_second || second_requests < requests_per_second
34
+
35
+ first_in_seconds = timestamps.find { |t| t > now - 1 }
36
+ sleep_time = (first_in_seconds + 1) - now
37
+
38
+ sleep(sleep_time) if sleep_time.positive?
39
+ end
40
+
41
+ def check_minute_window(now)
42
+ return if !requests_per_minute || timestamps.size < requests_per_minute
43
+
44
+ sleep_time = (timestamps.first + 60) - now
45
+
46
+ sleep(sleep_time) if sleep_time.positive?
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Easyship
4
- VERSION = '0.2.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/easyship.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require_relative 'easyship/version'
4
4
  require_relative 'easyship/configuration'
5
5
  require_relative 'easyship/client'
6
+ require_relative 'easyship/rate_limiting/rate_limiter'
7
+ require_relative 'easyship/rate_limiting/window_rate_limiter'
6
8
 
7
9
  # Provides configuration options for the Easyship gem.
8
10
  module Easyship
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easyship
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Marusyk
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-01-24 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: faraday
@@ -56,6 +57,8 @@ files:
56
57
  - lib/easyship/handlers/response_body_handler.rb
57
58
  - lib/easyship/middleware/error_handler_middleware.rb
58
59
  - lib/easyship/pagination/cursor.rb
60
+ - lib/easyship/rate_limiting/rate_limiter.rb
61
+ - lib/easyship/rate_limiting/window_rate_limiter.rb
59
62
  - lib/easyship/version.rb
60
63
  - sig/easyship.rbs
61
64
  homepage: https://rubygems.org/gems/easyship
@@ -67,6 +70,7 @@ metadata:
67
70
  allowed_push_host: https://rubygems.org
68
71
  source_code_uri: https://github.com/mmarusyk/easyship
69
72
  changelog_uri: https://github.com/mmarusyk/easyship/blob/main/CHANGELOG.md
73
+ post_install_message:
70
74
  rdoc_options: []
71
75
  require_paths:
72
76
  - lib
@@ -81,7 +85,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
81
85
  - !ruby/object:Gem::Version
82
86
  version: '0'
83
87
  requirements: []
84
- rubygems_version: 3.6.7
88
+ rubygems_version: 3.5.22
89
+ signing_key:
85
90
  specification_version: 4
86
91
  summary: A Ruby client for integrating with Easyship's API for shipping and logistics
87
92
  management.