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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RestRequestor
4
+ VERSION = "0.4.0"
5
+ 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: []