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