rs
1# frozen_string_literal: true
2
3module Net
4 module Hippie
5 # HTTP client with connection pooling, automatic retries, and JSON-first defaults.
6 #
7 # The Client class provides the core HTTP functionality for Net::Hippie, supporting
8 # all standard HTTP methods with intelligent defaults for JSON APIs. Features include:
9 #
10 # * Connection pooling and reuse per host
11 # * Automatic retry with exponential backoff
12 # * Redirect following with configurable limits
13 # * TLS/SSL support with client certificates
14 # * Comprehensive timeout configuration
15 # * Pluggable content-type mapping
16 #
17 # @since 0.1.0
18 #
19 # == Basic Usage
20 #
21 # client = Net::Hippie::Client.new
22 # response = client.get('https://api.github.com/users/octocat')
23 # data = JSON.parse(response.body)
24 #
25 # == Advanced Configuration
26 #
27 # client = Net::Hippie::Client.new(
28 # read_timeout: 30,
29 # open_timeout: 10,
30 # follow_redirects: 5,
31 # headers: { 'User-Agent' => 'MyApp/1.0' }
32 # )
33 #
34 # == Retry Logic
35 #
36 # # Automatic retries with exponential backoff
37 # response = client.with_retry(retries: 3) do |c|
38 # c.post('https://api.example.com/data', body: payload)
39 # end
40 #
41 # @see Net::Hippie The main module for simple usage
42 class Client
43 # Default HTTP headers sent with every request.
44 # Configured for JSON APIs with a descriptive User-Agent.
45 #
46 # @since 0.1.0
47 DEFAULT_HEADERS = {
48 'Accept' => 'application/json',
49 'Content-Type' => 'application/json',
50 'User-Agent' => "net/hippie #{Net::Hippie::VERSION}"
51 }.freeze
52
53 # @!attribute [r] mapper
54 # @return [ContentTypeMapper] Content type mapper for request bodies
55 # @!attribute [r] logger
56 # @return [Logger, nil] Logger instance for debugging
57 # @!attribute [r] follow_redirects
58 # @return [Integer] Maximum number of redirects to follow
59 attr_reader :mapper, :logger, :follow_redirects
60
61 # Creates a new HTTP client with optional configuration.
62 #
63 # @param options [Hash] Client configuration options
64 # @option options [ContentTypeMapper] :mapper Custom content-type mapper
65 # @option options [Logger, nil] :logger Logger for request debugging
66 # @option options [Integer] :follow_redirects Maximum redirects to follow (default: 0)
67 # @option options [Hash] :headers Default headers to merge with requests
68 # @option options [Integer] :read_timeout Socket read timeout in seconds (default: 10)
69 # @option options [Integer] :open_timeout Socket open timeout in seconds (default: 10)
70 # @option options [Integer] :verify_mode SSL verification mode (default: VERIFY_PEER)
71 # @option options [String] :certificate Client certificate for mutual TLS
72 # @option options [String] :key Private key for client certificate
73 # @option options [String] :passphrase Passphrase for encrypted private key
74 #
75 # @since 0.1.0
76 #
77 # @example Basic client
78 # client = Net::Hippie::Client.new
79 #
80 # @example Client with custom timeouts
81 # client = Net::Hippie::Client.new(
82 # read_timeout: 30,
83 # open_timeout: 5
84 # )
85 #
86 # @example Client with mutual TLS
87 # client = Net::Hippie::Client.new(
88 # certificate: File.read('client.crt'),
89 # key: File.read('client.key'),
90 # passphrase: 'secret'
91 # )
92 def initialize(options = {})
93 @options = options
94 @mapper = options.fetch(:mapper, ContentTypeMapper.new)
95 @logger = options.fetch(:logger, Net::Hippie.logger)
96 @follow_redirects = options.fetch(:follow_redirects, 0)
97 @default_headers = options.fetch(:headers, DEFAULT_HEADERS)
98 @connections = Hash.new do |hash, key|
99 scheme, host, port = key
100 hash[key] = Connection.new(scheme, host, port, options)
101 end
102 end
103
104 # Executes an HTTP request with automatic redirect following.
105 #
106 # @param uri [String, URI] The target URI for the request
107 # @param request [Net::HTTPRequest] The prepared HTTP request object
108 # @param limit [Integer] Maximum number of redirects to follow
109 # @yield [request, response] Optional block to process request/response
110 # @yieldparam request [Net::HTTPRequest] The HTTP request object
111 # @yieldparam response [Net::HTTPResponse] The HTTP response object
112 # @return [Net::HTTPResponse] The final HTTP response
113 # @raise [Net::ReadTimeout, Net::OpenTimeout] When request times out
114 # @since 0.1.0
115 def execute(uri, request, limit: follow_redirects, &block)
116 connection = connection_for(uri)
117 response = connection.run(request)
118 if limit.positive? && response.is_a?(Net::HTTPRedirection)
119 url = connection.build_url_for(response['location'])
120 request = request_for(Net::HTTP::Get, url)
121 execute(url, request, limit: limit - 1, &block)
122 else
123 block_given? ? yield(request, response) : response
124 end
125 end
126
127 # Performs an HTTP GET request.
128 #
129 # @param uri [String, URI] The target URI
130 # @param headers [Hash] Additional HTTP headers
131 # @param body [Hash, String] Request body (typically unused for GET)
132 # @yield [request, response] Optional block to process request/response
133 # @return [Net::HTTPResponse] The HTTP response
134 # @since 0.1.0
135 #
136 # @example Simple GET
137 # response = client.get('https://api.github.com/users/octocat')
138 #
139 # @example GET with custom headers
140 # response = client.get('https://api.example.com',
141 # headers: { 'Authorization' => 'Bearer token' })
142 def get(uri, headers: {}, body: {}, &block)
143 run(uri, Net::HTTP::Get, headers, body, &block)
144 end
145
146 # Performs an HTTP PATCH request.
147 #
148 # @param uri [String, URI] The target URI
149 # @param headers [Hash] Additional HTTP headers
150 # @param body [Hash, String] Request body data
151 # @yield [request, response] Optional block to process request/response
152 # @return [Net::HTTPResponse] The HTTP response
153 # @since 0.2.6
154 #
155 # @example Update resource
156 # response = client.patch('https://api.example.com/users/123',
157 # body: { name: 'Updated Name' })
158 def patch(uri, headers: {}, body: {}, &block)
159 run(uri, Net::HTTP::Patch, headers, body, &block)
160 end
161
162 # Performs an HTTP POST request.
163 #
164 # @param uri [String, URI] The target URI
165 # @param headers [Hash] Additional HTTP headers
166 # @param body [Hash, String] Request body data
167 # @yield [request, response] Optional block to process request/response
168 # @return [Net::HTTPResponse] The HTTP response
169 # @since 0.1.0
170 #
171 # @example Create resource
172 # response = client.post('https://api.example.com/users',
173 # body: { name: 'John', email: 'john@example.com' })
174 def post(uri, headers: {}, body: {}, &block)
175 run(uri, Net::HTTP::Post, headers, body, &block)
176 end
177
178 # Performs an HTTP PUT request.
179 #
180 # @param uri [String, URI] The target URI
181 # @param headers [Hash] Additional HTTP headers
182 # @param body [Hash, String] Request body data
183 # @yield [request, response] Optional block to process request/response
184 # @return [Net::HTTPResponse] The HTTP response
185 # @since 0.1.0
186 #
187 # @example Replace resource
188 # response = client.put('https://api.example.com/users/123',
189 # body: { name: 'John', email: 'john@example.com' })
190 def put(uri, headers: {}, body: {}, &block)
191 run(uri, Net::HTTP::Put, headers, body, &block)
192 end
193
194 # Performs an HTTP DELETE request.
195 #
196 # @param uri [String, URI] The target URI
197 # @param headers [Hash] Additional HTTP headers
198 # @param body [Hash, String] Request body (typically unused for DELETE)
199 # @yield [request, response] Optional block to process request/response
200 # @return [Net::HTTPResponse] The HTTP response
201 # @since 0.1.8
202 #
203 # @example Delete resource
204 # response = client.delete('https://api.example.com/users/123')
205 def delete(uri, headers: {}, body: {}, &block)
206 run(uri, Net::HTTP::Delete, headers, body, &block)
207 end
208
209 # Executes HTTP requests with automatic retry and exponential backoff.
210 #
211 # Retry logic with exponential backoff and jitter:
212 # * Attempt 1 -> delay 0.1 second
213 # * Attempt 2 -> delay 0.2 second
214 # * Attempt 3 -> delay 0.4 second
215 # * Attempt 4 -> delay 0.8 second
216 # * Attempt 5 -> delay 1.6 second
217 # * Attempt 6 -> delay 3.2 second
218 # * Attempt 7 -> delay 6.4 second
219 # * Attempt 8 -> delay 12.8 second
220 #
221 # Only retries on network-related errors defined in CONNECTION_ERRORS.
222 #
223 # @param retries [Integer] Maximum number of retry attempts (default: 3)
224 # @yield [client] Block that performs the HTTP request
225 # @yieldparam client [Client] The client instance to use for requests
226 # @return [Net::HTTPResponse] The successful HTTP response
227 # @raise [Net::ReadTimeout, Net::OpenTimeout] When all retry attempts fail
228 # @since 0.2.1
229 #
230 # @example Retry a POST request
231 # response = client.with_retry(retries: 5) do |c|
232 # c.post('https://api.unreliable.com/data', body: payload)
233 # end
234 #
235 # @example No retries
236 # response = client.with_retry(retries: 0) do |c|
237 # c.get('https://api.example.com/health')
238 # end
239 def with_retry(retries: 3)
240 retries = 0 if retries.nil? || retries.negative?
241
242 0.upto(retries) do |n|
243 attempt(n, retries) do
244 return yield self
245 end
246 end
247 end
248
249 private
250
251 attr_reader :default_headers
252
253 def attempt(attempt, max)
254 yield
255 rescue *CONNECTION_ERRORS => error
256 raise error if attempt == max
257
258 delay = ((2**attempt) * 0.1) + Random.rand(0.05) # delay + jitter
259 logger&.warn("`#{error.message}` #{attempt + 1}/#{max} Delay: #{delay}s")
260 sleep delay
261 end
262
263 def request_for(type, uri, headers: {}, body: {})
264 final_headers = default_headers.merge(headers)
265 type.new(URI.parse(uri.to_s), final_headers).tap do |x|
266 x.body = mapper.map_from(final_headers, body) unless body.empty?
267 end
268 end
269
270 def run(uri, http_method, headers, body, &block)
271 request = request_for(http_method, uri, headers: headers, body: body)
272 execute(uri, request, &block)
273 end
274
275 def connection_for(uri)
276 uri = URI.parse(uri.to_s)
277 @connections[[uri.scheme, uri.host, uri.port]]
278 end
279 end
280 end
281end