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 +4 -4
- data/lib/zotero/client.rb +35 -8
- data/lib/zotero/error.rb +17 -1
- data/lib/zotero/file_attachments.rb +12 -10
- data/lib/zotero/http_config.rb +20 -6
- data/lib/zotero/http_errors.rb +14 -7
- data/lib/zotero/library.rb +30 -22
- data/lib/zotero/syncing.rb +19 -14
- data/lib/zotero/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed5e1bc966a90e528cdcb874afebc4e6fb0b7941f0d1b641f25c686660b42a82
|
|
4
|
+
data.tar.gz: 07becabbc5d55a481b7d3f9945f671ec7ee183ee72cbbd8b84410f065679152e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
88
|
+
file_data = File.binread(file_path)
|
|
87
89
|
|
|
88
90
|
if auth_response["params"]
|
|
89
91
|
auth_response["params"].merge("file" => file_data)
|
data/lib/zotero/http_config.rb
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
data/lib/zotero/http_errors.rb
CHANGED
|
@@ -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: #{
|
|
19
|
-
when 401, 403 then raise AuthenticationError, "Authentication failed
|
|
20
|
-
when 404 then raise NotFoundError, "Resource not found"
|
|
21
|
-
when 409 then raise ConflictError, "Conflict: #{
|
|
22
|
-
when 412 then raise PreconditionFailedError, "Precondition failed: #{
|
|
23
|
-
when 428 then raise PreconditionRequiredError, "Precondition required: #{
|
|
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,
|
|
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)
|
data/lib/zotero/library.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
data/lib/zotero/syncing.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
37
|
-
|
|
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
|
data/lib/zotero/version.rb
CHANGED
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.
|
|
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.
|
|
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: []
|