zotero-rb 0.1.3 → 0.1.4

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: 4766ba9e6a279552e06f893d7a30c0689ada6c8166dbf59a1a8eaeaf56189887
4
- data.tar.gz: 4f37812a884e8079d1ff2643a0e21a40f05f487e9261bc5fb902959a16c39d6c
3
+ metadata.gz: fec7634f993dfdf13ab89d23638bb4a1754a386749fc375eee188c0731be65cc
4
+ data.tar.gz: af1f817fe3c696dc7f480100db3a6bc6d78a8ed902dfe4624840af931c11e97d
5
5
  SHA512:
6
- metadata.gz: d03e2f9b7eb2323eca3a6e896a22d295b80e999037c7a345c5e10bc60cc10fe79218b6f801b71ad89dffb657aa63f023047e7518cd5cc2c7105ddfbc763b32fa
7
- data.tar.gz: 25b58f33822bcae33b0f65a6090e744fc0f1f8198bc16ba23b87cfcc2703c679b7dc9f76d9139e1a257661265dc2bf907052130efdff1158975e15322621d0fd
6
+ metadata.gz: c7ee12554c2e9aff9a6e05e6929da003ff65fa09ebd5301e8eb56e44cfbf3c9048ad95578ede3945f09445a00175d38680dc95654f9408f067ca6e98dcf62a8f
7
+ data.tar.gz: c7351998cf6efff225f4b19dfdc6a8d14a5414fed82f1e81e8b68f9d8b20ead9f1a675fef2dfdd7902a97e38f540e6a849e9c31a8cc6846514c832a75494119e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.0](https://github.com/andrewhwaller/zotero-rb/compare/v0.1.2...v1.0.0) (2025-09-04)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * Internal HTTP implementation migrated from HTTParty to Net::HTTP
14
+
15
+ ### Features
16
+
17
+ * replace HTTParty with Net::HTTP to eliminate dependencies ([f1af7cc](https://github.com/andrewhwaller/zotero-rb/commit/f1af7ccd27cf401bd963062763b701f2b32ea923))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * avoid Digest mocking to resolve CI RSpec environment issues ([94adc4a](https://github.com/andrewhwaller/zotero-rb/commit/94adc4a7edd6fd8362c9794f1b1826fd8dda5ec9))
23
+ * move digest require to top level for test compatibility ([c88a64b](https://github.com/andrewhwaller/zotero-rb/commit/c88a64b8972018b9e01682cd9f405f0d3b5f4fec))
24
+
8
25
  ## [0.1.2](https://github.com/andrewhwaller/zotero-rb/compare/v0.1.1...v0.1.2) (2025-09-04)
9
26
 
10
27
 
data/lib/zotero/client.rb CHANGED
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "httparty"
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "cgi"
4
7
  require_relative "item_types"
5
8
  require_relative "fields"
6
9
  require_relative "file_upload"
7
10
  require_relative "http_errors"
8
11
  require_relative "syncing"
12
+ require_relative "http_config"
13
+ require_relative "http_connection"
14
+ require_relative "network_errors"
9
15
 
10
16
  module Zotero
11
17
  # The main HTTP client for interacting with the Zotero Web API v3.
@@ -15,15 +21,16 @@ module Zotero
15
21
  # client = Zotero::Client.new(api_key: 'your-api-key-here')
16
22
  # library = client.user_library(12345)
17
23
  #
24
+ # rubocop:disable Metrics/ClassLength
18
25
  class Client
19
- include HTTParty
20
26
  include ItemTypes
21
27
  include Fields
22
28
  include FileUpload
23
29
  include HTTPErrors
24
30
  include Syncing
31
+ include NetworkErrors
25
32
 
26
- base_uri "https://api.zotero.org"
33
+ BASE_URI = "https://api.zotero.org"
27
34
 
28
35
  # Initialize a new Zotero API client.
29
36
  #
@@ -33,44 +40,44 @@ module Zotero
33
40
  end
34
41
 
35
42
  def get(path, params: {})
36
- response = self.class.get(path,
37
- headers: auth_headers.merge(default_headers),
38
- query: params)
43
+ response = http_request(:get, path,
44
+ headers: auth_headers.merge(default_headers),
45
+ params: params)
39
46
  handle_response(response, params[:format])
40
47
  end
41
48
 
42
49
  def post(path, data:, version: nil, write_token: nil, params: {})
43
50
  headers = build_write_headers(version: version, write_token: write_token)
44
- response = self.class.post(path,
45
- headers: headers,
46
- body: data,
47
- query: params)
51
+ response = http_request(:post, path,
52
+ headers: headers,
53
+ body: data,
54
+ params: params)
48
55
  handle_write_response(response)
49
56
  end
50
57
 
51
58
  def patch(path, data:, version: nil, params: {})
52
59
  headers = build_write_headers(version: version)
53
- response = self.class.patch(path,
54
- headers: headers,
55
- body: data,
56
- query: params)
60
+ response = http_request(:patch, path,
61
+ headers: headers,
62
+ body: data,
63
+ params: params)
57
64
  handle_write_response(response)
58
65
  end
59
66
 
60
67
  def put(path, data:, version: nil, params: {})
61
68
  headers = build_write_headers(version: version)
62
- response = self.class.put(path,
63
- headers: headers,
64
- body: data,
65
- query: params)
69
+ response = http_request(:put, path,
70
+ headers: headers,
71
+ body: data,
72
+ params: params)
66
73
  handle_write_response(response)
67
74
  end
68
75
 
69
76
  def delete(path, version: nil, params: {})
70
77
  headers = build_write_headers(version: version)
71
- response = self.class.delete(path,
72
- headers: headers,
73
- query: params)
78
+ response = http_request(:delete, path,
79
+ headers: headers,
80
+ params: params)
74
81
  handle_write_response(response)
75
82
  end
76
83
 
@@ -90,6 +97,80 @@ module Zotero
90
97
  Library.new(client: self, type: :group, id: group_id)
91
98
  end
92
99
 
100
+ protected
101
+
102
+ def http_request(method, path, **options)
103
+ request_options = build_request_options(options)
104
+ uri = build_uri(path, request_options[:params])
105
+
106
+ handle_network_errors do
107
+ connection = HTTPConnection.new(uri)
108
+ request = build_request(method, uri, request_options[:headers], request_options[:body], request_options)
109
+
110
+ net_response = connection.request(request)
111
+ ResponseAdapter.new(net_response, uri)
112
+ end
113
+ end
114
+
115
+ def build_request_options(options)
116
+ {
117
+ headers: options[:headers] || {},
118
+ body: options[:body],
119
+ params: options[:params] || {},
120
+ multipart: options.dig(:options, :multipart),
121
+ format: options.dig(:options, :format)
122
+ }
123
+ end
124
+
125
+ def build_uri(path, params = {})
126
+ base = path.start_with?("http") ? path : "#{BASE_URI}#{path}"
127
+ uri = URI(base)
128
+
129
+ unless params.empty?
130
+ query_params = params.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&")
131
+ uri.query = uri.query ? "#{uri.query}&#{query_params}" : query_params
132
+ end
133
+
134
+ uri
135
+ end
136
+
137
+ def build_request(method, uri, headers, body, request_options)
138
+ request = create_request(method, uri)
139
+ set_headers(request, headers)
140
+ set_request_body(request, method, body, headers, request_options) if body
141
+ request
142
+ end
143
+
144
+ def create_request(method, uri)
145
+ request_class = case method
146
+ when :get then Net::HTTP::Get
147
+ when :post then Net::HTTP::Post
148
+ when :put then Net::HTTP::Put
149
+ when :patch then Net::HTTP::Patch
150
+ when :delete then Net::HTTP::Delete
151
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
152
+ end
153
+
154
+ request_class.new(uri)
155
+ end
156
+
157
+ def set_headers(request, headers)
158
+ headers.each { |key, value| request[key] = value }
159
+ end
160
+
161
+ def set_request_body(request, method, body, headers, request_options)
162
+ return unless %i[post put patch].include?(method)
163
+
164
+ if request_options[:multipart]
165
+ request.set_form(body, "multipart/form-data")
166
+ elsif headers["Content-Type"] == "application/x-www-form-urlencoded"
167
+ request.set_form_data(body)
168
+ else
169
+ request.body = body.is_a?(String) ? body : JSON.generate(body)
170
+ request["Content-Type"] = "application/json" unless headers["Content-Type"]
171
+ end
172
+ end
173
+
93
174
  private
94
175
 
95
176
  attr_reader :api_key
@@ -136,4 +217,63 @@ module Zotero
136
217
  end
137
218
  end
138
219
  end
220
+ # rubocop:enable Metrics/ClassLength
221
+
222
+ # Adapter to provide HTTParty-compatible interface for Net::HTTP responses
223
+ class ResponseAdapter
224
+ attr_reader :net_response, :uri
225
+
226
+ def initialize(net_response, uri)
227
+ @net_response = net_response
228
+ @uri = uri
229
+ end
230
+
231
+ def code
232
+ @net_response.code.to_i
233
+ end
234
+
235
+ def parsed_response
236
+ @parsed_response ||= parse_body
237
+ end
238
+
239
+ def body
240
+ @net_response.body
241
+ end
242
+
243
+ def headers
244
+ @headers ||= @net_response.to_hash.transform_values(&:first)
245
+ end
246
+
247
+ def message
248
+ @net_response.message
249
+ end
250
+
251
+ def request
252
+ @request ||= RequestAdapter.new(@uri)
253
+ end
254
+
255
+ private
256
+
257
+ def parse_body
258
+ content_type = @net_response.content_type
259
+ return nil if @net_response.body.nil? || @net_response.body.empty?
260
+
261
+ if content_type&.include?("application/json")
262
+ JSON.parse(@net_response.body)
263
+ else
264
+ @net_response.body
265
+ end
266
+ rescue JSON::ParserError
267
+ @net_response.body
268
+ end
269
+ end
270
+
271
+ # Adapter to provide request.path access for error handling
272
+ class RequestAdapter
273
+ attr_reader :path
274
+
275
+ def initialize(uri)
276
+ @path = uri.path
277
+ end
278
+ end
139
279
  end
@@ -9,12 +9,12 @@ module Zotero
9
9
  headers["If-Match"] = if_match if if_match
10
10
  headers["If-None-Match"] = if_none_match if if_none_match
11
11
 
12
- response = self.class.post(path, headers: headers, body: form_data, query: params)
12
+ response = http_request(:post, path, headers: headers, body: form_data, params: params)
13
13
  handle_response(response)
14
14
  end
15
15
 
16
16
  def external_post(url, multipart_data:)
17
- response = self.class.post(url, body: multipart_data, multipart: true, format: :plain)
17
+ response = http_request(:post, url, body: multipart_data, options: { multipart: true, format: :plain })
18
18
 
19
19
  case response.code
20
20
  when 200..299
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ # Configuration for HTTP requests
5
+ class HTTPConfig
6
+ attr_accessor :open_timeout, :read_timeout, :verify_ssl
7
+
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
12
+ end
13
+
14
+ def self.default
15
+ @default ||= new
16
+ end
17
+
18
+ def self.configure
19
+ yield(default) if block_given?
20
+ default
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ module Zotero
7
+ # Manages HTTP connections with proper SSL configuration and timeouts
8
+ class HTTPConnection
9
+ DEFAULT_OPEN_TIMEOUT = 30
10
+ DEFAULT_READ_TIMEOUT = 60
11
+
12
+ def initialize(uri, config = nil)
13
+ @uri = uri
14
+ @config = config || HTTPConfig.default
15
+ @http = build_connection
16
+ end
17
+
18
+ def request(net_request)
19
+ configure_connection unless @configured
20
+ @http.request(net_request)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :uri, :config, :http
26
+
27
+ def build_connection
28
+ Net::HTTP.new(@uri.host, @uri.port)
29
+ end
30
+
31
+ def configure_connection
32
+ configure_ssl if @uri.scheme == "https"
33
+ configure_timeouts
34
+ @configured = true
35
+ end
36
+
37
+ def configure_ssl
38
+ @http.use_ssl = true
39
+ @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
40
+ @http.ca_file = OpenSSL::X509::DEFAULT_CERT_FILE
41
+ end
42
+
43
+ def configure_timeouts
44
+ @http.open_timeout = @config.open_timeout
45
+ @http.read_timeout = @config.read_timeout
46
+ end
47
+ end
48
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Zotero
4
6
  # File upload operations for library items
5
7
  module LibraryFileOperations
@@ -36,8 +38,6 @@ module Zotero
36
38
  end
37
39
 
38
40
  def extract_file_metadata(file_path)
39
- require "digest"
40
-
41
41
  {
42
42
  filename: File.basename(file_path),
43
43
  md5: Digest::MD5.file(file_path).hexdigest,
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "openssl"
5
+
6
+ module Zotero
7
+ # Handles network errors and translates them to appropriate Zotero exceptions
8
+ module NetworkErrors
9
+ ERROR_MESSAGES = {
10
+ Errno::ECONNREFUSED => "Connection refused - server may be down",
11
+ Errno::EHOSTUNREACH => "Host unreachable - check network connectivity",
12
+ Errno::ENETUNREACH => "Host unreachable - check network connectivity",
13
+ SocketError => "DNS resolution failed - check hostname",
14
+ Net::OpenTimeout => "Connection timeout - server took too long to respond",
15
+ Net::ReadTimeout => "Read timeout - server response was too slow",
16
+ OpenSSL::SSL::SSLError => "SSL error - certificate verification failed",
17
+ Timeout::Error => "Request timeout"
18
+ }.freeze
19
+
20
+ def handle_network_errors
21
+ yield
22
+ rescue *network_error_classes => e
23
+ raise translate_network_error(e)
24
+ end
25
+
26
+ private
27
+
28
+ def network_error_classes
29
+ [
30
+ Errno::ECONNREFUSED,
31
+ Errno::EHOSTUNREACH,
32
+ Errno::ENETUNREACH,
33
+ SocketError,
34
+ Net::OpenTimeout,
35
+ Net::ReadTimeout,
36
+ OpenSSL::SSL::SSLError,
37
+ Timeout::Error
38
+ ]
39
+ end
40
+
41
+ def translate_network_error(error)
42
+ message = error_message_for(error)
43
+ Error.new("#{message} (#{error.class})")
44
+ end
45
+
46
+ def error_message_for(error)
47
+ ERROR_MESSAGES.fetch(error.class) { "Network error: #{error.message}" }
48
+ end
49
+ end
50
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zotero
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
metadata CHANGED
@@ -1,42 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zotero-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Waller
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: csv
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
26
- - !ruby/object:Gem::Dependency
27
- name: httparty
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: 0.21.0
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: 0.21.0
11
+ dependencies: []
40
12
  description: A feature-complete Ruby client for the Zotero Web API v3, providing full
41
13
  CRUD operations for items, collections, tags, and searches, plus file uploads, fulltext
42
14
  content access, and library synchronization. Built with a modular architecture and
@@ -62,10 +34,13 @@ files:
62
34
  - lib/zotero/fields.rb
63
35
  - lib/zotero/file_upload.rb
64
36
  - lib/zotero/fulltext.rb
37
+ - lib/zotero/http_config.rb
38
+ - lib/zotero/http_connection.rb
65
39
  - lib/zotero/http_errors.rb
66
40
  - lib/zotero/item_types.rb
67
41
  - lib/zotero/library.rb
68
42
  - lib/zotero/library_file_operations.rb
43
+ - lib/zotero/network_errors.rb
69
44
  - lib/zotero/syncing.rb
70
45
  - lib/zotero/version.rb
71
46
  - sig/zotero/rb.rbs