powo_ruby 0.1.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.
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PowoRuby
4
+ # Main POWO client.
5
+ #
6
+ # Public API is endpoint-oriented (e.g. `client.search.query`, `client.taxa.lookup`).
7
+ #
8
+ # In most cases you won't instantiate this directly; prefer `PowoRuby.powo` / `PowoRuby.ipni`.
9
+ #
10
+ # @example
11
+ # client = PowoRuby.powo
12
+ # response = client.search.query(query: "Acacia", filters: { accepted: true })
13
+ # response.results.first #=> Hash
14
+ class Client
15
+ OPTION_KEYS =
16
+ %i[
17
+ base_url
18
+ timeout
19
+ open_timeout
20
+ max_retries
21
+ retries
22
+ logger
23
+ cache
24
+ cache_options
25
+ cache_namespace
26
+ user_agent
27
+ terms_path
28
+ ].freeze
29
+
30
+ # Create a client instance.
31
+ #
32
+ # @param mode [Symbol, String] `:powo` or `:ipni` (controls allowed params and grouping)
33
+ # @param options [Hash, nil] base option hash (usually from {PowoRuby::Configuration#client_kwargs})
34
+ # @param overrides [Hash] per-instance overrides merged into options
35
+ #
36
+ # Supported keys are listed in `OPTION_KEYS`. Unknown keys raise `ArgumentError`.
37
+ #
38
+ # @return [PowoRuby::Client]
39
+ def initialize(
40
+ mode: :powo,
41
+ options: nil,
42
+ **overrides
43
+ )
44
+ @mode = mode.to_sym
45
+ opts = merge_options(options, overrides)
46
+ @terms = Terms.load(opts.fetch(:terms_path))
47
+
48
+ @request = Request.new(
49
+ user_agent: opts.fetch(:user_agent),
50
+ base_url: opts.fetch(:base_url),
51
+ timeout: opts.fetch(:timeout),
52
+ open_timeout: opts.fetch(:open_timeout),
53
+ max_retries: opts.fetch(:max_retries),
54
+ retries: opts.fetch(:retries),
55
+ logger: opts.fetch(:logger),
56
+ cache: opts.fetch(:cache),
57
+ cache_options: opts.fetch(:cache_options),
58
+ cache_namespace: opts.fetch(:cache_namespace)
59
+ )
60
+ end
61
+
62
+ # Access the `/search` endpoint wrapper.
63
+ #
64
+ # @return [PowoRuby::Resources::Search]
65
+ def search
66
+ @search ||= Resources::Search.new(request: request, allowed_params: allowed_params, group_keys: group_keys)
67
+ end
68
+
69
+ # Access the `/taxon/<id>` endpoint wrapper.
70
+ #
71
+ # @return [PowoRuby::Resources::Taxa]
72
+ def taxa
73
+ @taxa ||= Resources::Taxa.new(request: request)
74
+ end
75
+
76
+ private
77
+
78
+ attr_reader :request, :terms
79
+
80
+ def merge_options(options, overrides)
81
+ base = PowoRuby.config.client_kwargs
82
+ merged = base.merge(normalize_options_hash(options)).merge(normalize_options_hash(overrides))
83
+ unknown = merged.keys - OPTION_KEYS
84
+ raise ArgumentError, "Unknown client option keys: #{unknown.inspect}" unless unknown.empty?
85
+
86
+ merged
87
+ end
88
+
89
+ def normalize_options_hash(hash)
90
+ return {} if hash.nil?
91
+ raise ArgumentError, "options must be a Hash" unless hash.is_a?(Hash)
92
+
93
+ hash.each_with_object({}) do |(k, v), out|
94
+ key = k.is_a?(String) || k.is_a?(Symbol) ? k.to_sym : k
95
+ out[key] = v
96
+ end
97
+ end
98
+
99
+ def allowed_params
100
+ case @mode
101
+ when :powo
102
+ terms.powo_allowed_params
103
+ when :ipni
104
+ terms.ipni_allowed_params
105
+ else
106
+ raise ArgumentError, "Unknown client mode: #{@mode.inspect} (expected :powo or :ipni)"
107
+ end
108
+ end
109
+
110
+ def group_keys
111
+ case @mode
112
+ when :powo
113
+ %i[name characteristic geography]
114
+ when :ipni
115
+ %i[name author publication]
116
+ else
117
+ raise ArgumentError, "Unknown client mode: #{@mode.inspect} (expected :powo or :ipni)"
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PowoRuby
4
+ # Resolves a client instance for the module-level API.
5
+ #
6
+ # `config` may be:
7
+ # - nil (use the default, memoized client)
8
+ # - Hash (merge into defaults for this call)
9
+ # - PowoRuby::Configuration
10
+ # - a client instance of the requested klass
11
+ class ClientResolver
12
+ # Resolve a client instance based on the `config` argument.
13
+ #
14
+ # @param klass [Class] client class to construct (usually {PowoRuby::Client})
15
+ # @param config [nil, Hash, PowoRuby::Configuration, Object] see class docstring
16
+ # @param memo_key [Symbol] thread-local key used for memoization when config is nil
17
+ # @param default_overrides [Hash] overrides always applied when building new instances
18
+ # @return [Object] instance of `klass`
19
+ def self.resolve(klass, config:, memo_key:, default_overrides: {})
20
+ case config
21
+ when klass
22
+ config
23
+ when Hash
24
+ klass.new(options: PowoRuby.config.with(config).client_kwargs, **default_overrides)
25
+ when Configuration
26
+ klass.new(options: config.client_kwargs, **default_overrides)
27
+ when nil
28
+ Thread.current[memo_key] ||= klass.new(
29
+ options: PowoRuby.config.client_kwargs,
30
+ **default_overrides
31
+ )
32
+ else
33
+ raise ArgumentError, "config must be nil, a Hash, PowoRuby::Configuration, or #{klass}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PowoRuby
4
+ # Global configuration for the convenience constructors (`PowoRuby.powo`, `PowoRuby.ipni`).
5
+ #
6
+ # Users can override defaults via:
7
+ #
8
+ # PowoRuby.configure do |c|
9
+ # c.base_url = "..."
10
+ # c.timeout = 5
11
+ # end
12
+ #
13
+ class Configuration
14
+ attr_accessor :base_url, :timeout, :open_timeout, :max_retries, :retries, :logger,
15
+ :cache, :cache_options, :cache_namespace,
16
+ :user_agent, :terms_path
17
+
18
+ def initialize
19
+ @base_url = "https://powo.science.kew.org/api/2"
20
+ @timeout = 10
21
+ @open_timeout = 5
22
+ @max_retries = 3
23
+
24
+ @retries = true
25
+ @logger = nil
26
+ @cache = nil
27
+ @cache_options = {}
28
+ @cache_namespace = nil
29
+ @user_agent = "powo_ruby/#{PowoRuby::VERSION}"
30
+ @terms_path = File.expand_path("../../../docs/POWO_SEARCH_TERMS.md", __dir__)
31
+ end
32
+
33
+ # Keyword arguments used to build a {PowoRuby::Client}/{PowoRuby::Request}.
34
+ #
35
+ # @return [Hash] normalized options hash suitable for `Client.new(options: ...)`
36
+ def client_kwargs
37
+ {
38
+ base_url: base_url,
39
+ timeout: timeout,
40
+ open_timeout: open_timeout,
41
+ max_retries: max_retries,
42
+ retries: retries,
43
+ logger: logger,
44
+ cache: cache,
45
+ cache_options: cache_options,
46
+ cache_namespace: cache_namespace,
47
+ user_agent: user_agent,
48
+ terms_path: terms_path
49
+ }
50
+ end
51
+
52
+ # Returns a copy of this configuration with selected overrides applied.
53
+ #
54
+ # This is used internally when `PowoRuby.powo(config: { ... })` is called.
55
+ #
56
+ # @param overrides [Hash{Symbol,String => Object}]
57
+ # @return [PowoRuby::Configuration]
58
+ def with(overrides)
59
+ dup.tap do |copy|
60
+ overrides.each do |k, v|
61
+ writer = "#{k}="
62
+ raise ArgumentError, "Unknown configuration key: #{k.inspect}" unless copy.respond_to?(writer)
63
+
64
+ copy.public_send(writer, v)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PowoRuby
4
+ # Base error class for this gem.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when configuration is invalid or incomplete.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when user input fails validation (e.g. missing query, wrong type).
11
+ class ValidationError < Error; end
12
+
13
+ # Raised when an HTTP request fails or cannot be processed.
14
+ #
15
+ # Most request errors include contextual fields like HTTP status, URL and response body.
16
+ class RequestError < Error
17
+ attr_reader :status, :method, :url, :body, :headers
18
+
19
+ # @param message [String]
20
+ # @param status [Integer, nil] HTTP status code
21
+ # @param method [Symbol, String, nil] HTTP method (e.g. `:get`)
22
+ # @param url [String, nil] full URL requested
23
+ # @param body [Object, nil] response body (may be a String or parsed JSON)
24
+ # @param headers [Hash, nil] response headers
25
+ def initialize(message, status: nil, method: nil, url: nil, body: nil, headers: nil)
26
+ super(message)
27
+ @status = status
28
+ @method = method
29
+ @url = url
30
+ @body = body
31
+ @headers = headers
32
+ end
33
+ end
34
+
35
+ # Raised for 4xx responses (excluding 429).
36
+ class ClientError < RequestError; end
37
+
38
+ # Raised for HTTP 429 responses (rate limiting).
39
+ class RateLimitedError < RequestError; end
40
+
41
+ # Raised for 5xx responses.
42
+ class ServerError < RequestError; end
43
+
44
+ # Raised when the request times out.
45
+ class TimeoutError < RequestError; end
46
+
47
+ # Raised when Faraday cannot establish a connection.
48
+ class ConnectionFailedError < RequestError; end
49
+
50
+ # Raised when JSON parsing fails for a successful response.
51
+ class ParseError < RequestError; end
52
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PowoRuby
4
+ # Generic, Response-driven paginator for page-based APIs.
5
+ #
6
+ # POWO primarily uses cursor-based paging for search, but this utility exists as a
7
+ # reusable helper for any endpoints that return {PowoRuby::Response} objects with
8
+ # `#each` and `#next_page?`.
9
+ class Paginator
10
+ # Build an enumerator that fetches pages starting at `start_page`.
11
+ #
12
+ # The block must return a {PowoRuby::Response} (or any object responding to `#each`
13
+ # and `#next_page?`).
14
+ #
15
+ # @param start_page [Integer]
16
+ # @yieldparam page [Integer]
17
+ # @yieldreturn [#each,#next_page?]
18
+ # @return [Enumerator]
19
+ #
20
+ # @example
21
+ # enum = PowoRuby::Paginator.enumerator do |page|
22
+ # client.some_endpoint.page(page: page)
23
+ # end
24
+ def self.enumerator(start_page: 1, &fetch_page)
25
+ raise ArgumentError, "block required" unless fetch_page
26
+
27
+ Enumerator.new do |y|
28
+ current_page = Integer(start_page)
29
+
30
+ loop do
31
+ response = fetch_page.call(current_page)
32
+ response.each { |row| y << row }
33
+
34
+ break unless response.next_page?
35
+
36
+ current_page += 1
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "uri"
5
+
6
+ require_relative "request_support/cache_key_builder"
7
+ require_relative "request_support/cache_store"
8
+ require_relative "request_support/response_handler"
9
+ require_relative "request_support/retry_policy"
10
+
11
+ module PowoRuby
12
+ # Central HTTP wrapper for POWO requests.
13
+ #
14
+ # Defensive by default:
15
+ # - timeouts
16
+ # - retries with exponential backoff for 429 and 5xx
17
+ # - JSON parsing with helpful errors
18
+ #
19
+ # This class is considered an internal building block; most users will interact with it
20
+ # indirectly via {PowoRuby::Client} and endpoint wrappers.
21
+ class Request
22
+ # @param user_agent [String] sent as the `User-Agent` header
23
+ # @param base_url [String] POWO API base, e.g. `https://powo.science.kew.org/api/2`
24
+ # @param timeout [Numeric] request timeout (seconds)
25
+ # @param open_timeout [Numeric] connection open timeout (seconds)
26
+ # @param max_retries [Integer] number of retries (not counting the first attempt)
27
+ # @param backoff_base [Numeric] base backoff seconds for retries
28
+ # @param backoff_max [Numeric] max backoff seconds for retries
29
+ # @param retries [Boolean] enable/disable retry behavior
30
+ # @param logger [#warn, nil] optional logger
31
+ # @param cache_kwargs [Hash] cache configuration
32
+ # @option cache_kwargs [#fetch, nil] :cache cache adapter (e.g. Rails.cache)
33
+ # @option cache_kwargs [Hash] :cache_options adapter options (e.g. `{ expires_in: 60 }`)
34
+ # @option cache_kwargs [String, nil] :cache_namespace namespace used in cache keys
35
+ def initialize(
36
+ user_agent:,
37
+ base_url:,
38
+ timeout:,
39
+ open_timeout:,
40
+ max_retries:,
41
+ backoff_base: 0.5,
42
+ backoff_max: 8.0,
43
+ retries: true,
44
+ logger: nil,
45
+ **cache_kwargs
46
+ )
47
+ raise ConfigurationError, "base_url must be provided" if base_url.to_s.strip.empty?
48
+ raise ConfigurationError, "user_agent must be provided" if user_agent.to_s.strip.empty?
49
+
50
+ allowed_cache_kwargs = %i[cache cache_options cache_namespace]
51
+ unknown_cache_kwargs = cache_kwargs.keys - allowed_cache_kwargs
52
+ unless unknown_cache_kwargs.empty?
53
+ raise ArgumentError, "Unknown Request cache kwargs: #{unknown_cache_kwargs.inspect}"
54
+ end
55
+
56
+ @base_url = base_url
57
+ @user_agent = user_agent
58
+ @timeout = timeout
59
+ @open_timeout = open_timeout
60
+ @max_retries = Integer(max_retries)
61
+ @backoff_base = Float(backoff_base)
62
+ @backoff_max = Float(backoff_max)
63
+ @retry_enabled = retries ? true : false
64
+ @logger = logger
65
+ @cache = cache_kwargs[:cache]
66
+ @cache_options = cache_kwargs[:cache_options] || {}
67
+ @cache_namespace = cache_kwargs[:cache_namespace]
68
+
69
+ @cache_store = RequestSupport::CacheStore.new(cache)
70
+ @cache_key_builder = RequestSupport::CacheKeyBuilder.new
71
+ @retry_policy =
72
+ RequestSupport::RetryPolicy.new(
73
+ enabled: @retry_enabled,
74
+ max_retries: @max_retries,
75
+ backoff_base: @backoff_base,
76
+ backoff_max: @backoff_max,
77
+ logger: @logger
78
+ )
79
+ @response_handler = RequestSupport::ResponseHandler.new
80
+ end
81
+
82
+ attr_reader :base_url, :user_agent, :timeout, :open_timeout, :max_retries, :backoff_base, :backoff_max,
83
+ :retry_enabled, :logger, :cache, :cache_options, :cache_namespace
84
+
85
+ # Convenience GET wrapper used by endpoints.
86
+ #
87
+ # @param path [String] endpoint path relative to the base URL (e.g. `"search"`)
88
+ # @param params [Hash] query string params
89
+ # @return [Hash, Array] parsed JSON response
90
+ def get(path, params: {})
91
+ request(:get, path, params: params)
92
+ end
93
+
94
+ private
95
+
96
+ # ... private helpers ...
97
+
98
+ def request(method, path, params:)
99
+ url = build_url(path)
100
+ cache_key =
101
+ @cache_key_builder.build(
102
+ method: method,
103
+ url: url,
104
+ params: params,
105
+ namespace: cache_namespace,
106
+ version: PowoRuby::VERSION
107
+ )
108
+
109
+ @cache_store.fetch(cache_key) do
110
+ @retry_policy.with_retry(method: method, url: url) do
111
+ response = connection.send(method) do |req|
112
+ req.url(path)
113
+ req.params.update(stringify_keys(params))
114
+ req.headers["Accept"] = "application/json"
115
+ req.headers["User-Agent"] = user_agent
116
+ end
117
+
118
+ @response_handler.handle(response, method: method, url: url)
119
+ end
120
+ end
121
+ rescue Faraday::TimeoutError => e
122
+ raise TimeoutError.new(e.message, method: method, url: url, body: nil)
123
+ rescue Faraday::ConnectionFailed => e
124
+ raise ConnectionFailedError.new(e.message, method: method, url: url, body: nil)
125
+ end
126
+
127
+ def connection
128
+ @connection ||= Faraday.new(url: base_url) do |conn|
129
+ conn.options.timeout = timeout
130
+ conn.options.open_timeout = open_timeout
131
+ conn.request :url_encoded
132
+ conn.adapter Faraday.default_adapter
133
+ end
134
+ end
135
+
136
+ def build_url(path)
137
+ URI.join(base_url.end_with?("/") ? base_url : "#{base_url}/", path.sub(%r{\A/+}, "")).to_s
138
+ end
139
+
140
+ def stringify_keys(hash)
141
+ return {} if hash.nil?
142
+
143
+ hash.transform_keys(&:to_s)
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module PowoRuby
6
+ module RequestSupport
7
+ # Builds a stable cache key for a request.
8
+ #
9
+ # The goal is:
10
+ # - stable ordering (hash keys sorted)
11
+ # - support nested params (bracket notation)
12
+ # - repeat keys for arrays (k=v1&k=v2)
13
+ class CacheKeyBuilder
14
+ # Build a cache key string.
15
+ #
16
+ # @param method [Symbol, String]
17
+ # @param url [String] fully qualified URL (without query)
18
+ # @param params [Hash] query params
19
+ # @param namespace [String, nil] optional namespace included in the key
20
+ # @param version [String, nil] optional gem version included in the key
21
+ # @return [String]
22
+ def build(method:, url:, params:, namespace: nil, version: nil)
23
+ pairs = flatten_params(stringify_keys(params))
24
+ query = pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
25
+
26
+ prefix =
27
+ [
28
+ "powo_ruby",
29
+ (namespace.to_s.strip.empty? ? nil : "ns=#{namespace}"),
30
+ (version.to_s.strip.empty? ? nil : "v=#{version}")
31
+ ].compact.join(" ")
32
+
33
+ "#{prefix} #{method.to_s.upcase} #{url}#{query}"
34
+ end
35
+
36
+ private
37
+
38
+ # @param hash [Hash, nil]
39
+ # @return [Hash]
40
+ def stringify_keys(hash)
41
+ return {} if hash.nil?
42
+
43
+ hash.transform_keys(&:to_s)
44
+ end
45
+
46
+ # Produces a stable, URI-encodable list of [key, value] pairs.
47
+ # - Hash keys are sorted for stability.
48
+ # - Array values become repeated keys (k=v1&k=v2), preserving array order.
49
+ # - Nested hashes are supported using bracket notation: a[b]=1
50
+ #
51
+ # @param obj [Object]
52
+ # @param prefix [String, nil]
53
+ # @return [Array<Array(String, String)>]
54
+ def flatten_params(obj, prefix = nil)
55
+ case obj
56
+ when nil
57
+ []
58
+ when Hash
59
+ obj
60
+ .sort_by { |k, _| k.to_s }
61
+ .flat_map do |k, v|
62
+ key = prefix ? "#{prefix}[#{k}]" : k.to_s
63
+ flatten_params(v, key)
64
+ end
65
+ when Array
66
+ obj.flat_map do |v|
67
+ # If no prefix, we can't encode a value without a key.
68
+ next [] if prefix.nil?
69
+
70
+ flatten_params(v, prefix)
71
+ end
72
+ else
73
+ return [] if prefix.nil?
74
+
75
+ [[prefix.to_s, obj.to_s]]
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PowoRuby
4
+ module RequestSupport
5
+ # Thin adapter around a user-provided cache object.
6
+ #
7
+ # Supports a range of adapters:
8
+ # - Rails cache (`fetch(key, options = nil) { ... }`)
9
+ # - minimal custom caches (`fetch(key) { ... }`)
10
+ #
11
+ # If `cache` does not respond to `fetch`, this acts like a no-op cache and always yields.
12
+ class CacheStore
13
+ # @param cache [Object] cache adapter
14
+ def initialize(cache)
15
+ @cache = cache
16
+ end
17
+
18
+ # Fetch a value from the cache (or compute it).
19
+ #
20
+ # @param key [String]
21
+ # @param options [Hash, nil] adapter-specific fetch options (e.g. TTL)
22
+ # @yieldreturn [Object]
23
+ # @return [Object]
24
+ def fetch(key, options = nil, &block)
25
+ return block.call unless @cache.respond_to?(:fetch)
26
+
27
+ return @cache.fetch(key, &block) if options.nil? || (options.respond_to?(:empty?) && options.empty?)
28
+
29
+ # Prefer passing options (e.g., ActiveSupport::Cache supports `fetch(key, options = nil) { ... }`),
30
+ # but fall back to a plain `fetch(key)` for minimal adapters.
31
+ @cache.fetch(key, options, &block)
32
+ rescue ArgumentError
33
+ @cache.fetch(key, &block)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../errors"
6
+
7
+ module PowoRuby
8
+ module RequestSupport
9
+ # Translates an HTTP response into either parsed JSON or a rich error.
10
+ #
11
+ # POWO typically returns JSON bodies; this handler:
12
+ # - raises typed errors for 4xx/5xx/429
13
+ # - parses JSON for successful responses
14
+ class ResponseHandler
15
+ # Handle an HTTP response.
16
+ #
17
+ # @param response [#status,#body,#headers]
18
+ # @param method [Symbol, String]
19
+ # @param url [String]
20
+ # @return [Hash, Array]
21
+ def handle(response, method:, url:)
22
+ status = response.status.to_i
23
+ body = response.body
24
+ headers = response.headers
25
+
26
+ if status == 429
27
+ raise RateLimitedError.new(
28
+ "Rate limited by POWO (HTTP 429)",
29
+ status: status,
30
+ method: method,
31
+ url: url,
32
+ body: body,
33
+ headers: headers
34
+ )
35
+ end
36
+
37
+ if status >= 500
38
+ raise ServerError.new(
39
+ "POWO server error (HTTP #{status})",
40
+ status: status,
41
+ method: method,
42
+ url: url,
43
+ body: body,
44
+ headers: headers
45
+ )
46
+ end
47
+
48
+ if status >= 400
49
+ raise ClientError.new(
50
+ "POWO request failed (HTTP #{status})",
51
+ status: status,
52
+ method: method,
53
+ url: url,
54
+ body: body,
55
+ headers: headers
56
+ )
57
+ end
58
+
59
+ parse_json(body, method: method, url: url)
60
+ end
61
+
62
+ private
63
+
64
+ # Parse a JSON response body.
65
+ #
66
+ # @param body [String, Hash, Array, Object]
67
+ # @param method [Symbol, String]
68
+ # @param url [String]
69
+ # @return [Hash, Array, Object]
70
+ def parse_json(body, method:, url:)
71
+ return body if body.is_a?(Hash) || body.is_a?(Array)
72
+
73
+ text = body.to_s
74
+ return {} if text.strip.empty?
75
+
76
+ JSON.parse(text)
77
+ rescue JSON::ParserError => e
78
+ raise ParseError.new("Failed to parse JSON response: #{e.message}", method: method, url: url, body: body)
79
+ end
80
+ end
81
+ end
82
+ end