rest_requestor 0.4.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/lib/rest_requestor/config.rb +73 -0
- data/lib/rest_requestor/version.rb +5 -0
- data/lib/rest_requestor.rb +235 -0
- metadata +44 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6c16e96223b812472703d763390c9795e11058d2c27a78d35c317a8ef8a53f2e
|
|
4
|
+
data.tar.gz: 96250ea144fb1e39f0f4621a92519eb0fec001cedf50ed82eaf670bc49517a28
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 43f6a99dece90901df0b5413eb83e08a128fb77fddbd5d0674df82af223c3752d84dbeb665146e0aa96aae0c64a26b870ced5ab31d9b9a635298b41f708838a4
|
|
7
|
+
data.tar.gz: 6cd37575e2d8e1c6fa17b4f7754f05d9791eb9612d0e8a40e883356c442d7f425034b38d0667a7208aa4f0b1ee1256a76df83c2ab7add13d96d37a3175e8ddfa
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
class RestRequestor
|
|
6
|
+
module Config
|
|
7
|
+
# Each tier holds candidate paths; the first found in a tier is used for that tier.
|
|
8
|
+
# Tiers are merged least-to-most-specific, so project settings override env-explicit,
|
|
9
|
+
# which override home/global settings. Settings absent from a more-specific tier are
|
|
10
|
+
# inherited from less-specific ones.
|
|
11
|
+
CONFIG_TIERS = [
|
|
12
|
+
# Tier 1 — home/global (least specific)
|
|
13
|
+
[
|
|
14
|
+
-> { ENV["XDG_CONFIG_HOME"]&.then { |d| File.join(d, "rest_requestor", "config.yml") } },
|
|
15
|
+
-> { File.join(Dir.home, ".config", "rest_requestor", "config.yml") },
|
|
16
|
+
-> { File.join(Dir.home, ".rest_requestor_config.yml") }
|
|
17
|
+
],
|
|
18
|
+
# Tier 2 — explicit path via environment variable
|
|
19
|
+
[
|
|
20
|
+
-> { (v = ENV["REST_REQUESTOR_CONFIG"]) && !v.empty? ? v : nil }
|
|
21
|
+
],
|
|
22
|
+
# Tier 3 — project-local (most specific)
|
|
23
|
+
[
|
|
24
|
+
-> { File.join(Dir.pwd, "config", "rest_requestor_config.yml") }
|
|
25
|
+
]
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def self.load
|
|
29
|
+
CONFIG_TIERS.each_with_object({}) do |tier, result|
|
|
30
|
+
data = first_in_tier(tier)
|
|
31
|
+
result.merge!(parse(data)) if data
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.parse(data)
|
|
36
|
+
result = {}
|
|
37
|
+
|
|
38
|
+
if (raw_ua = data["user_agent"] || data[:user_agent])
|
|
39
|
+
sanitized = raw_ua.to_s.gsub(/[^A-Za-z0-9_\-]/, "_")
|
|
40
|
+
result[:user_agent] = "#{sanitized}/#{VERSION}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if (v = data["max_retries"] || data[:max_retries])
|
|
44
|
+
n = v.to_i
|
|
45
|
+
result[:max_retries] = n if n > 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
%i[open_timeout read_timeout].each do |key|
|
|
49
|
+
if (v = data[key.to_s] || data[key])
|
|
50
|
+
n = v.to_f
|
|
51
|
+
result[key] = n if n > 0
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.first_in_tier(locations)
|
|
59
|
+
locations.each do |location|
|
|
60
|
+
path = location.call
|
|
61
|
+
next unless path && File.exist?(path)
|
|
62
|
+
begin
|
|
63
|
+
data = YAML.safe_load(File.read(path))
|
|
64
|
+
return data if data.is_a?(Hash)
|
|
65
|
+
rescue ::StandardError
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
private_class_method :first_in_tier
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require_relative "rest_requestor/version"
|
|
8
|
+
require_relative "rest_requestor/config"
|
|
9
|
+
|
|
10
|
+
class RestRequestor
|
|
11
|
+
class StandardError < StandardError
|
|
12
|
+
def initialize(uri, verb, resp, params = {}, msg: nil)
|
|
13
|
+
body = if resp["Content-Type"]&.include?("text/html")
|
|
14
|
+
if (m = resp.body.to_s.match(/<title>(.*)<\/title>/))
|
|
15
|
+
m[1]
|
|
16
|
+
else
|
|
17
|
+
resp.body.to_s[0..20]
|
|
18
|
+
end
|
|
19
|
+
else
|
|
20
|
+
resp.body.to_s
|
|
21
|
+
end
|
|
22
|
+
msg ||= "uri: #{uri}, Verb: #{verb}, Resp: #{resp.code} - #{resp.message}, Body: #{body}, Params: #{params}"
|
|
23
|
+
super(msg)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class RateLimitError < StandardError; end
|
|
28
|
+
class NotFoundError < StandardError; end
|
|
29
|
+
class ServiceUnavailableError < StandardError; end
|
|
30
|
+
class TooManyRedirectsError < ::StandardError; end
|
|
31
|
+
|
|
32
|
+
attr_reader :resp, :elapsed, :options
|
|
33
|
+
|
|
34
|
+
OPTION_DEFAULTS = {
|
|
35
|
+
max_retries: 7,
|
|
36
|
+
open_timeout: 10,
|
|
37
|
+
read_timeout: 30,
|
|
38
|
+
proxy_address: "127.0.0.1",
|
|
39
|
+
proxy_port: 9090,
|
|
40
|
+
user_agent: nil,
|
|
41
|
+
skip_proxy: true,
|
|
42
|
+
logger: nil
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
FILE_CONFIG = Config.load.freeze
|
|
46
|
+
|
|
47
|
+
# Shared across instances; cert store is immutable after setup
|
|
48
|
+
def self.ssl_cert_store
|
|
49
|
+
@ssl_cert_store ||= begin
|
|
50
|
+
store = OpenSSL::X509::Store.new
|
|
51
|
+
store.set_default_paths
|
|
52
|
+
# Prevent failures when CRL distribution points are unreachable
|
|
53
|
+
store.flags = OpenSSL::X509::V_FLAG_PARTIAL_CHAIN
|
|
54
|
+
store
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(**kw_args)
|
|
59
|
+
@options = OPTION_DEFAULTS.merge(FILE_CONFIG).merge(kw_args)
|
|
60
|
+
warn_no_user_agent if options[:user_agent].to_s.strip.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def request(uri, verb, j_params: {}, f_hash: {}, q_params: {}, auth: nil, headers: {}, body: nil, retries: 0)
|
|
64
|
+
unless [:get, :post, :put, :delete, :patch, :head].include?(verb)
|
|
65
|
+
raise ArgumentError, "Verb must be one of :get, :post, :put, :delete, :patch, :head — got #{verb.inspect}"
|
|
66
|
+
end
|
|
67
|
+
uri = add_query_string(uri, q_params) if q_params && !q_params.empty?
|
|
68
|
+
|
|
69
|
+
exec_req(uri, verb, j_params, f_hash, auth, headers, body)
|
|
70
|
+
|
|
71
|
+
case resp.code.to_i
|
|
72
|
+
when (200...300)
|
|
73
|
+
return resp.code.to_i if verb == :head
|
|
74
|
+
if resp["Content-Type"]&.include?("application/json")
|
|
75
|
+
JSON.parse(resp.body)
|
|
76
|
+
elsif resp.code.to_i == 200 && !resp.body.to_s.strip.empty?
|
|
77
|
+
begin
|
|
78
|
+
JSON.parse(resp.body.to_s)
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
200
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
resp.code.to_i
|
|
84
|
+
end
|
|
85
|
+
when 404 then raise NotFoundError.new(uri, verb, resp, j_params)
|
|
86
|
+
when 429
|
|
87
|
+
# Caller must handle rate limits — retry semantics vary per service
|
|
88
|
+
raise RateLimitError.new(uri, verb, resp, j_params)
|
|
89
|
+
when 503
|
|
90
|
+
if retries <= options[:max_retries]
|
|
91
|
+
retries += 1
|
|
92
|
+
waitfor = retry_delay(retries)
|
|
93
|
+
options[:logger]&.info { "Retrying (#{retries}), pausing #{waitfor} seconds" }
|
|
94
|
+
sleep waitfor
|
|
95
|
+
# q_params already incorporated into uri above; omit here to avoid double-encoding
|
|
96
|
+
request(uri, verb, j_params: j_params, f_hash: f_hash, auth: auth, headers: headers, body: body, retries: retries)
|
|
97
|
+
else
|
|
98
|
+
raise ServiceUnavailableError.new(uri, verb, resp, j_params)
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
raise StandardError.new(uri, verb, resp, j_params)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_redirects(url, limit = 10)
|
|
106
|
+
uri = URI.parse(url)
|
|
107
|
+
|
|
108
|
+
limit.times do
|
|
109
|
+
http = build_http(uri)
|
|
110
|
+
req = Net::HTTP::Head.new(uri.request_uri)
|
|
111
|
+
req["User-Agent"] = options[:user_agent] unless options[:user_agent].to_s.strip.empty?
|
|
112
|
+
response = http.request(req)
|
|
113
|
+
return uri.to_s unless response.is_a?(Net::HTTPRedirection)
|
|
114
|
+
|
|
115
|
+
uri = URI.join(uri, response["location"])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
raise TooManyRedirectsError, "Too many redirects resolving #{url}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def warn_no_user_agent
|
|
124
|
+
msg = "[RestRequestor] No user_agent set — many APIs require one. " \
|
|
125
|
+
"Pass user_agent: \"YourApp/1.0\" to the initializer or set it in a config file."
|
|
126
|
+
options[:logger]&.warn(msg)
|
|
127
|
+
Kernel.warn(msg)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def retry_delay(retries)
|
|
131
|
+
# Approx seconds: 1~2.5, 2~7.5, 3~17.5, 4~37.5, 5~77.5, 6~157.5, 7~317.3
|
|
132
|
+
2**retries + rand(0..(2**retries / 2.0))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def add_query_string(uri, q_params)
|
|
136
|
+
separator = uri.include?("?") ? "&" : "?"
|
|
137
|
+
uri + separator + URI.encode_www_form(q_params)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_http(uri_obj)
|
|
141
|
+
http = if proxy_alive?
|
|
142
|
+
Net::HTTP.new(uri_obj.host, uri_obj.port, options[:proxy_address], options[:proxy_port])
|
|
143
|
+
else
|
|
144
|
+
Net::HTTP.new(uri_obj.host, uri_obj.port)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if uri_obj.scheme == "https"
|
|
148
|
+
http.use_ssl = true
|
|
149
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
150
|
+
http.cert_store = self.class.ssl_cert_store
|
|
151
|
+
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
http.open_timeout = options[:open_timeout]
|
|
155
|
+
http.read_timeout = options[:read_timeout]
|
|
156
|
+
http
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def exec_req(uri, verb, j_params, f_hash, auth, headers, body)
|
|
160
|
+
uri_obj = URI(uri)
|
|
161
|
+
http = build_http(uri_obj)
|
|
162
|
+
|
|
163
|
+
request_class = case verb
|
|
164
|
+
when :get then Net::HTTP::Get
|
|
165
|
+
when :post then Net::HTTP::Post
|
|
166
|
+
when :put then Net::HTTP::Put
|
|
167
|
+
when :delete then Net::HTTP::Delete
|
|
168
|
+
when :patch then Net::HTTP::Patch
|
|
169
|
+
when :head then Net::HTTP::Head
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
request_path = uri_obj.path
|
|
173
|
+
request_path += "?#{uri_obj.query}" if uri_obj.query
|
|
174
|
+
request_path = "/" if request_path.empty?
|
|
175
|
+
|
|
176
|
+
req = request_class.new(request_path)
|
|
177
|
+
req["User-Agent"] = options[:user_agent] unless options[:user_agent].to_s.strip.empty?
|
|
178
|
+
|
|
179
|
+
headers.each { |key, value| req[key.to_s] = value }
|
|
180
|
+
|
|
181
|
+
if !auth.nil?
|
|
182
|
+
if auth.is_a?(String) && !auth.strip.empty?
|
|
183
|
+
req["Authorization"] = auth
|
|
184
|
+
elsif auth.is_a?(Array) && auth.length == 2
|
|
185
|
+
req.basic_auth(*auth)
|
|
186
|
+
elsif auth.is_a?(Hash) && auth.keys.length == 2
|
|
187
|
+
# Assumes two-key {user:, password:} shape; order determines which is which
|
|
188
|
+
req.basic_auth(*auth.values)
|
|
189
|
+
elsif auth.respond_to?(:call)
|
|
190
|
+
auth_header = auth.call
|
|
191
|
+
req["Authorization"] = auth_header if auth_header
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if !j_params.nil? && !j_params.empty?
|
|
196
|
+
req["Content-Type"] = "application/json"
|
|
197
|
+
req.body = j_params.to_json
|
|
198
|
+
elsif !f_hash.nil? && !f_hash.empty?
|
|
199
|
+
req.set_form_data(f_hash)
|
|
200
|
+
elsif !body.nil?
|
|
201
|
+
req.body = body
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
r_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
205
|
+
@resp = http.request(req)
|
|
206
|
+
r_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
207
|
+
@elapsed = r_end - r_start
|
|
208
|
+
|
|
209
|
+
@resp
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def proxy_alive?(refresh: false)
|
|
213
|
+
remove_instance_variable(:@proxy_alive) if refresh
|
|
214
|
+
return @proxy_alive if defined?(@proxy_alive)
|
|
215
|
+
|
|
216
|
+
if options[:skip_proxy] || %w[1 true yes].include?(ENV["REST_REQUESTOR_SKIP_PROXY"].to_s.downcase)
|
|
217
|
+
return @proxy_alive = false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
begin
|
|
221
|
+
uri = URI("https://httpbin.org/ip")
|
|
222
|
+
http = Net::HTTP.new(uri.host, uri.port, options[:proxy_address], options[:proxy_port])
|
|
223
|
+
http.use_ssl = true
|
|
224
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
225
|
+
http.cert_store = self.class.ssl_cert_store
|
|
226
|
+
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
227
|
+
http.open_timeout = 5
|
|
228
|
+
http.read_timeout = 5
|
|
229
|
+
http.request(Net::HTTP::Head.new("/"))
|
|
230
|
+
@proxy_alive = true
|
|
231
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError
|
|
232
|
+
@proxy_alive = false
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rest_requestor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.4.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Scott Wright
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Wraps Net::HTTP with JSON handling, exponential backoff on 503 errors,
|
|
13
|
+
optional proxy support, and typed error classes.
|
|
14
|
+
email:
|
|
15
|
+
- scott@wrightzone.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- lib/rest_requestor.rb
|
|
21
|
+
- lib/rest_requestor/config.rb
|
|
22
|
+
- lib/rest_requestor/version.rb
|
|
23
|
+
homepage: https://codeberg.org/jswright61/rest_requestor
|
|
24
|
+
licenses:
|
|
25
|
+
- MIT
|
|
26
|
+
metadata: {}
|
|
27
|
+
rdoc_options: []
|
|
28
|
+
require_paths:
|
|
29
|
+
- lib
|
|
30
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
31
|
+
requirements:
|
|
32
|
+
- - ">="
|
|
33
|
+
- !ruby/object:Gem::Version
|
|
34
|
+
version: '3.0'
|
|
35
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
requirements: []
|
|
41
|
+
rubygems_version: 4.0.10
|
|
42
|
+
specification_version: 4
|
|
43
|
+
summary: HTTP client with retry support, proxy awareness, and structured errors
|
|
44
|
+
test_files: []
|