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.
- checksums.yaml +7 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/Rakefile +12 -0
- data/docs/POWO_SEARCH_TERMS.md +63 -0
- data/exe/powo_ruby +134 -0
- data/lib/powo_ruby/client.rb +121 -0
- data/lib/powo_ruby/client_resolver.rb +37 -0
- data/lib/powo_ruby/configuration.rb +69 -0
- data/lib/powo_ruby/errors.rb +52 -0
- data/lib/powo_ruby/paginator.rb +41 -0
- data/lib/powo_ruby/request.rb +146 -0
- data/lib/powo_ruby/request_support/cache_key_builder.rb +80 -0
- data/lib/powo_ruby/request_support/cache_store.rb +37 -0
- data/lib/powo_ruby/request_support/response_handler.rb +82 -0
- data/lib/powo_ruby/request_support/retry_policy.rb +86 -0
- data/lib/powo_ruby/resources/search.rb +217 -0
- data/lib/powo_ruby/resources/taxa.rb +36 -0
- data/lib/powo_ruby/response.rb +71 -0
- data/lib/powo_ruby/terms.rb +149 -0
- data/lib/powo_ruby/uri_utils.rb +20 -0
- data/lib/powo_ruby/validation.rb +44 -0
- data/lib/powo_ruby/version.rb +6 -0
- data/lib/powo_ruby.rb +109 -0
- data/sig/powo_ruby.rbs +6 -0
- metadata +85 -0
|
@@ -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
|