zotero-rb 0.1.5 → 0.2.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: 6480cd849dc46519c7a122f454e93f34befae86b340703dedc69781c59cb9a37
4
- data.tar.gz: c9890904401b2589eef07693b5e42fb798bbf81779fc2449e941cd6a1a33110b
3
+ metadata.gz: ed5e1bc966a90e528cdcb874afebc4e6fb0b7941f0d1b641f25c686660b42a82
4
+ data.tar.gz: 07becabbc5d55a481b7d3f9945f671ec7ee183ee72cbbd8b84410f065679152e
5
5
  SHA512:
6
- metadata.gz: 5c15301b1d99903ee690fca6dc77de020122fdf82ea46fe156238e16ef792b44e0ab6847548ff6bf9a563264a255719fb1caada748adbf9d8c0e1b0f35aaa4a7
7
- data.tar.gz: a25a0ad4d3e46e8577380c653fc25f472232c5d631451135f5c3acba468d8970f9cf21f4789b9ad691a1f2b2a5e4ca8651d06cef1f7e7081d42f619da404b2f5
6
+ metadata.gz: d57c1a5cbf941b10c9871352715cf2f9e04859a9c32cc6dfb34c911797d5c419ee74bfb7a1e2c1bc7ce64283636155867867142c094eaea85abacc2606d84890
7
+ data.tar.gz: 449b2c7e9bede8968cf0a99a7d270e3796a09390aefd3ea4228bd14c29967113bf2d5aa01b97d497fc168b335ebf71e11cc9682a42f30ef6ba9afddf9be49615
data/lib/zotero/client.rb CHANGED
@@ -63,9 +63,11 @@ module Zotero
63
63
  # @param params [Hash] Query parameters for the request
64
64
  # @return [Array, Hash] The parsed response data
65
65
  def make_get_request(path, params: {})
66
- headers = auth_headers.merge(default_headers)
67
- response = http_request(:get, path, headers: headers, params: params)
68
- handle_response(response, params[:format])
66
+ with_retry do
67
+ headers = auth_headers.merge(default_headers)
68
+ response = http_request(:get, path, headers: headers, params: params)
69
+ handle_response(response, params[:format])
70
+ end
69
71
  end
70
72
 
71
73
  # Make a write request (POST, PATCH, PUT, DELETE) to the Zotero API.
@@ -78,9 +80,11 @@ module Zotero
78
80
  # @param params [Hash] Query parameters for the request
79
81
  # @return [Hash, Boolean] The parsed response data or success status
80
82
  def make_write_request(method, path, data: nil, options: {}, params: {})
81
- headers = build_write_headers(version: options[:version], write_token: options[:write_token])
82
- response = http_request(method, path, headers: headers, body: data, params: params)
83
- handle_write_response(response)
83
+ with_retry do
84
+ headers = build_write_headers(version: options[:version], write_token: options[:write_token])
85
+ response = http_request(method, path, headers: headers, body: data, params: params)
86
+ handle_write_response(response)
87
+ end
84
88
  end
85
89
 
86
90
  protected
@@ -134,6 +138,29 @@ module Zotero
134
138
 
135
139
  attr_reader :api_key
136
140
 
141
+ def with_retry
142
+ config = HTTPConfig.default
143
+ attempts = 0
144
+
145
+ begin
146
+ yield
147
+ rescue RateLimitError => e
148
+ attempts += 1
149
+ raise unless should_retry?(config, attempts)
150
+
151
+ sleep(calculate_retry_delay(e, attempts, config))
152
+ retry
153
+ end
154
+ end
155
+
156
+ def should_retry?(config, attempts)
157
+ config.retry_on_rate_limit && attempts <= config.max_retries
158
+ end
159
+
160
+ def calculate_retry_delay(error, attempt, config)
161
+ [error.wait_time, config.base_delay * (2**(attempt - 1))].max
162
+ end
163
+
137
164
  def build_request_options(options)
138
165
  {
139
166
  headers: options[:headers] || {},
@@ -206,8 +233,8 @@ module Zotero
206
233
  return nil if response.body.nil? || response.body.empty?
207
234
 
208
235
  JSON.parse(response.body)
209
- rescue JSON::ParserError
210
- response.body
236
+ rescue JSON::ParserError => e
237
+ raise ParseError, "Failed to parse JSON response: #{e.message}"
211
238
  end
212
239
  end
213
240
  # rubocop:enable Metrics/ClassLength
data/lib/zotero/error.rb CHANGED
@@ -3,11 +3,27 @@
3
3
  module Zotero
4
4
  class Error < StandardError; end
5
5
  class AuthenticationError < Error; end
6
- class RateLimitError < Error; end
6
+
7
+ # Raised when the API rate limit is exceeded
8
+ class RateLimitError < Error
9
+ attr_reader :retry_after, :backoff
10
+
11
+ def initialize(message, retry_after: nil, backoff: nil)
12
+ super(message)
13
+ @retry_after = retry_after
14
+ @backoff = backoff
15
+ end
16
+
17
+ def wait_time
18
+ @retry_after || @backoff || 1
19
+ end
20
+ end
21
+
7
22
  class NotFoundError < Error; end
8
23
  class BadRequestError < Error; end
9
24
  class ServerError < Error; end
10
25
  class ConflictError < Error; end
11
26
  class PreconditionFailedError < Error; end
12
27
  class PreconditionRequiredError < Error; end
28
+ class ParseError < Error; end
13
29
  end
@@ -70,20 +70,22 @@ module Zotero
70
70
  end
71
71
 
72
72
  def perform_external_upload(auth_response, file_path, upload_path)
73
- if auth_response["url"]
74
- upload_params = build_upload_params(auth_response, file_path)
75
- @client.external_post(auth_response["url"], multipart_data: upload_params)
76
- end
73
+ upload_to_storage(auth_response, file_path) if auth_response["url"]
74
+ register_upload_completion(auth_response, upload_path)
75
+ end
77
76
 
78
- if auth_response["uploadKey"]
79
- @client.register_upload(upload_path, upload_key: auth_response["uploadKey"])
80
- else
81
- true
82
- end
77
+ def upload_to_storage(auth_response, file_path)
78
+ @client.external_post(auth_response["url"], multipart_data: build_upload_params(auth_response, file_path))
79
+ end
80
+
81
+ def register_upload_completion(auth_response, upload_path)
82
+ return true unless auth_response["uploadKey"]
83
+
84
+ @client.register_upload(upload_path, upload_key: auth_response["uploadKey"])
83
85
  end
84
86
 
85
87
  def build_upload_params(auth_response, file_path)
86
- file_data = File.open(file_path, "rb")
88
+ file_data = File.binread(file_path)
87
89
 
88
90
  if auth_response["params"]
89
91
  auth_response["params"].merge("file" => file_data)
@@ -1,14 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zotero
4
- # Configuration for HTTP requests
4
+ # Configuration for HTTP requests and retry behavior
5
5
  class HTTPConfig
6
- attr_accessor :open_timeout, :read_timeout, :verify_ssl
6
+ attr_accessor :open_timeout, :read_timeout, :verify_ssl,
7
+ :retry_on_rate_limit, :max_retries, :base_delay
7
8
 
8
- def initialize(open_timeout: 30, read_timeout: 60, verify_ssl: true)
9
- @open_timeout = open_timeout
10
- @read_timeout = read_timeout
11
- @verify_ssl = verify_ssl
9
+ DEFAULTS = {
10
+ open_timeout: 30,
11
+ read_timeout: 60,
12
+ verify_ssl: true,
13
+ retry_on_rate_limit: true,
14
+ max_retries: 3,
15
+ base_delay: 1.0
16
+ }.freeze
17
+
18
+ def initialize(**options)
19
+ config = DEFAULTS.merge(options)
20
+ @open_timeout = config[:open_timeout]
21
+ @read_timeout = config[:read_timeout]
22
+ @verify_ssl = config[:verify_ssl]
23
+ @retry_on_rate_limit = config[:retry_on_rate_limit]
24
+ @max_retries = config[:max_retries]
25
+ @base_delay = config[:base_delay]
12
26
  end
13
27
 
14
28
  def self.default
@@ -14,16 +14,23 @@ module Zotero
14
14
 
15
15
  def raise_client_error(response)
16
16
  code = response.code.to_i
17
+ detail = error_detail(response)
18
+
17
19
  case code
18
- when 400, 413 then raise BadRequestError, "Bad request: #{response.body}"
19
- when 401, 403 then raise AuthenticationError, "Authentication failed - check your API key"
20
- when 404 then raise NotFoundError, "Resource not found"
21
- when 409 then raise ConflictError, "Conflict: #{response.body}"
22
- when 412 then raise PreconditionFailedError, "Precondition failed: #{response.body}"
23
- when 428 then raise PreconditionRequiredError, "Precondition required: #{response.body}"
20
+ when 400, 413 then raise BadRequestError, "Bad request: #{detail}"
21
+ when 401, 403 then raise AuthenticationError, "Authentication failed: #{detail}"
22
+ when 404 then raise NotFoundError, "Resource not found: #{detail}"
23
+ when 409 then raise ConflictError, "Conflict: #{detail}"
24
+ when 412 then raise PreconditionFailedError, "Precondition failed: #{detail}"
25
+ when 428 then raise PreconditionRequiredError, "Precondition required: #{detail}"
24
26
  end
25
27
  end
26
28
 
29
+ def error_detail(response)
30
+ body = response.body.to_s.strip
31
+ body.empty? ? "(no details)" : body
32
+ end
33
+
27
34
  def raise_rate_limit_error(response)
28
35
  headers = response.to_hash.transform_values(&:first)
29
36
  backoff = headers["backoff"]&.to_i
@@ -31,7 +38,7 @@ module Zotero
31
38
  message = "Rate limited."
32
39
  message += " Backoff: #{backoff}s" if backoff
33
40
  message += " Retry after: #{retry_after}s" if retry_after
34
- raise RateLimitError, message
41
+ raise RateLimitError.new(message, retry_after: retry_after, backoff: backoff)
35
42
  end
36
43
 
37
44
  def raise_server_or_unknown_error(response)
@@ -26,10 +26,11 @@ module Zotero
26
26
  # @param client [Client] The Zotero client instance
27
27
  # @param type [String, Symbol] The library type (:user or :group)
28
28
  # @param id [Integer, String] The library ID (user ID or group ID)
29
+ # @raise [ArgumentError] if type is invalid or id is not a positive integer
29
30
  def initialize(client:, type:, id:)
30
31
  @client = client
31
32
  @type = validate_type(type)
32
- @id = id
33
+ @id = validate_id(id)
33
34
  @base_path = "/#{@type}s/#{@id}"
34
35
  end
35
36
 
@@ -38,7 +39,7 @@ module Zotero
38
39
  # @param params [Hash] Query parameters for the request
39
40
  # @return [Array, Hash] Collections data from the API
40
41
  def collections(**params)
41
- @client.make_get_request("#{@base_path}/collections", params: params)
42
+ client.make_get_request("#{base_path}/collections", params: params)
42
43
  end
43
44
 
44
45
  # Get items in this library.
@@ -46,7 +47,7 @@ module Zotero
46
47
  # @param params [Hash] Query parameters for the request
47
48
  # @return [Array, Hash] Items data from the API
48
49
  def items(**params)
49
- @client.make_get_request("#{@base_path}/items", params: params)
50
+ client.make_get_request("#{base_path}/items", params: params)
50
51
  end
51
52
 
52
53
  # Get saved searches in this library.
@@ -54,7 +55,7 @@ module Zotero
54
55
  # @param params [Hash] Query parameters for the request
55
56
  # @return [Array, Hash] Saved searches data from the API
56
57
  def searches(**params)
57
- @client.make_get_request("#{@base_path}/searches", params: params)
58
+ client.make_get_request("#{base_path}/searches", params: params)
58
59
  end
59
60
 
60
61
  # Get tags in this library.
@@ -62,7 +63,7 @@ module Zotero
62
63
  # @param params [Hash] Query parameters for the request
63
64
  # @return [Array, Hash] Tags data from the API
64
65
  def tags(**params)
65
- @client.make_get_request("#{@base_path}/tags", params: params)
66
+ client.make_get_request("#{base_path}/tags", params: params)
66
67
  end
67
68
 
68
69
  # Create a new item in this library.
@@ -92,8 +93,8 @@ module Zotero
92
93
  # @param version [Integer] Version for optimistic concurrency control
93
94
  # @return [Hash] The API response
94
95
  def update_item(item_key, item_data, version: nil)
95
- @client.make_write_request(:patch, "#{@base_path}/items/#{item_key}", data: item_data,
96
- options: { version: version })
96
+ client.make_write_request(:patch, "#{base_path}/items/#{item_key}", data: item_data,
97
+ options: { version: version })
97
98
  end
98
99
 
99
100
  # Delete an item from this library.
@@ -102,7 +103,7 @@ module Zotero
102
103
  # @param version [Integer] Version for optimistic concurrency control
103
104
  # @return [Boolean] Success status
104
105
  def delete_item(item_key, version: nil)
105
- @client.make_write_request(:delete, "#{@base_path}/items/#{item_key}", options: { version: version })
106
+ client.make_write_request(:delete, "#{base_path}/items/#{item_key}", options: { version: version })
106
107
  end
107
108
 
108
109
  # Delete multiple items from this library.
@@ -111,8 +112,8 @@ module Zotero
111
112
  # @param version [Integer] Version for optimistic concurrency control
112
113
  # @return [Boolean] Success status
113
114
  def delete_items(item_keys, version: nil)
114
- @client.make_write_request(:delete, "#{@base_path}/items", options: { version: version },
115
- params: { itemKey: item_keys.join(",") })
115
+ client.make_write_request(:delete, "#{base_path}/items", options: { version: version },
116
+ params: { itemKey: item_keys.join(",") })
116
117
  end
117
118
 
118
119
  # Create a new collection in this library.
@@ -142,8 +143,8 @@ module Zotero
142
143
  # @param version [Integer] Version for optimistic concurrency control
143
144
  # @return [Hash] The API response
144
145
  def update_collection(collection_key, collection_data, version: nil)
145
- @client.make_write_request(:patch, "#{@base_path}/collections/#{collection_key}", data: collection_data,
146
- options: { version: version })
146
+ client.make_write_request(:patch, "#{base_path}/collections/#{collection_key}", data: collection_data,
147
+ options: { version: version })
147
148
  end
148
149
 
149
150
  # Delete a collection from this library.
@@ -152,7 +153,7 @@ module Zotero
152
153
  # @param version [Integer] Version for optimistic concurrency control
153
154
  # @return [Boolean] Success status
154
155
  def delete_collection(collection_key, version: nil)
155
- @client.make_write_request(:delete, "#{@base_path}/collections/#{collection_key}", options: { version: version })
156
+ client.make_write_request(:delete, "#{base_path}/collections/#{collection_key}", options: { version: version })
156
157
  end
157
158
 
158
159
  # Delete multiple collections from this library.
@@ -161,9 +162,9 @@ module Zotero
161
162
  # @param version [Integer] Version for optimistic concurrency control
162
163
  # @return [Boolean] Success status
163
164
  def delete_collections(collection_keys, version: nil)
164
- @client.make_write_request(:delete, "#{@base_path}/collections",
165
- options: { version: version },
166
- params: { collectionKey: collection_keys.join(",") })
165
+ client.make_write_request(:delete, "#{base_path}/collections",
166
+ options: { version: version },
167
+ params: { collectionKey: collection_keys.join(",") })
167
168
  end
168
169
 
169
170
  private
@@ -171,15 +172,15 @@ module Zotero
171
172
  attr_reader :client, :type, :id, :base_path
172
173
 
173
174
  def create_single(resource, data, version: nil, write_token: nil)
174
- @client.make_write_request(:post, "#{@base_path}/#{resource}",
175
- data: [data],
176
- options: { version: version, write_token: write_token })
175
+ client.make_write_request(:post, "#{base_path}/#{resource}",
176
+ data: [data],
177
+ options: { version: version, write_token: write_token })
177
178
  end
178
179
 
179
180
  def create_multiple(resource, data_array, version: nil, write_token: nil)
180
- @client.make_write_request(:post, "#{@base_path}/#{resource}",
181
- data: data_array,
182
- options: { version: version, write_token: write_token })
181
+ client.make_write_request(:post, "#{base_path}/#{resource}",
182
+ data: data_array,
183
+ options: { version: version, write_token: write_token })
183
184
  end
184
185
 
185
186
  def validate_type(type)
@@ -190,5 +191,12 @@ module Zotero
190
191
 
191
192
  type_str
192
193
  end
194
+
195
+ def validate_id(id)
196
+ id_int = Integer(id, exception: false)
197
+ raise ArgumentError, "Invalid library ID: #{id.inspect}. Must be a positive integer" unless id_int&.positive?
198
+
199
+ id_int
200
+ end
193
201
  end
194
202
  end
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zotero
4
- # Syncing and API key verification methods
4
+ # Syncing and API key verification methods.
5
+ #
6
+ # This module can be included in both Client and Library classes.
7
+ # Classes including this module must implement #api_client which returns
8
+ # the object that responds to #make_get_request.
5
9
  module Syncing
6
10
  # Verify that the current API key is valid.
7
11
  #
8
12
  # @return [Hash] API key information including userID and username
9
13
  def verify_api_key
10
- if @client
11
- @client.make_get_request("/keys/current")
12
- else
13
- make_get_request("/keys/current")
14
- end
14
+ api_client.make_get_request("/keys/current")
15
15
  end
16
16
 
17
17
  # Get groups for a specific user.
@@ -20,21 +20,26 @@ module Zotero
20
20
  # @param format [String] Response format ('versions' or 'json')
21
21
  # @return [Hash, Array] Groups data in the requested format
22
22
  def user_groups(user_id, format: "versions")
23
- params = { format: format }
24
- if @client
25
- @client.make_get_request("/users/#{user_id}/groups", params: params)
26
- else
27
- make_get_request("/users/#{user_id}/groups", params: params)
28
- end
23
+ api_client.make_get_request("/users/#{user_id}/groups", params: { format: format })
29
24
  end
30
25
 
31
26
  # Get items that have been deleted from this library.
27
+ # Only available when included in Library.
32
28
  #
33
29
  # @param since [Integer] Optional version to get deletions since
34
30
  # @return [Hash] Object with deleted collections and items arrays
35
31
  def deleted_items(since: nil)
36
- params = since ? { since: since } : {}
37
- @client.make_get_request("#{@base_path}/deleted", params: params)
32
+ api_client.make_get_request("#{@base_path}/deleted", params: { since: since }.compact)
33
+ end
34
+
35
+ private
36
+
37
+ # Returns the object that handles API requests.
38
+ # Override in including classes if needed.
39
+ #
40
+ # @return [Object] The API client object
41
+ def api_client
42
+ @client || self
38
43
  end
39
44
  end
40
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zotero
4
- VERSION = "0.1.5"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zotero-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Waller
@@ -70,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
70
  - !ruby/object:Gem::Version
71
71
  version: '0'
72
72
  requirements: []
73
- rubygems_version: 3.7.1
73
+ rubygems_version: 3.7.2
74
74
  specification_version: 4
75
75
  summary: A comprehensive Ruby client for the Zotero Web API v3
76
76
  test_files: []