cli
 1# frozen_string_literal: true
 2
 3module Scim
 4  module Kit
 5    class Http
 6      Result = Struct.new(:status, :body, :unparsed) do
 7        def ok?
 8          !status.nil? && (200..299).cover?(status) && !unparsed
 9        end
10      end
11
12      MAX_REDIRECTS = 3
13
14      attr_reader :driver, :retries
15
16      def initialize(driver: Http.default_driver, retries: 3)
17        @driver = driver
18        @retries = retries
19      end
20
21      def get(uri)
22        result = fetch(uri)
23        result.ok? ? result.body : {}
24      end
25
26      def fetch(uri, headers: {})
27        driver.with_retry(retries: retries) do |client|
28          result_for(get_following_redirects(client, uri, headers))
29        end
30      rescue *Net::Hippie::CONNECTION_ERRORS => error
31        Scim::Kit.logger.error(error)
32        Result.new(nil, { detail: error.message })
33      end
34
35      # No :logger here on purpose -- net-hippie hands it to
36      # Net::HTTP#set_debug_output, which dumps raw requests (credentials
37      # included) to the log.
38      def self.default_driver
39        @default_driver ||= Net::Hippie::Client.new(
40          follow_redirects: 0,
41          headers: headers,
42          open_timeout: 1,
43          read_timeout: 5
44        )
45      end
46
47      def self.headers
48        {
49          'Accept' => 'application/scim+json',
50          'Content-Type' => 'application/scim+json',
51          'User-Agent' => "scim/kit #{Scim::Kit::VERSION}"
52        }
53      end
54
55      private
56
57      # net-hippie rebuilds the redirected request without the per-request
58      # headers, so follow redirects here to keep them.
59      def get_following_redirects(client, uri, headers, limit: MAX_REDIRECTS)
60        uri = URI.parse(uri.to_s)
61        response = client.get(uri, headers: headers)
62        location = response['location'] if response.is_a?(Net::HTTPRedirection)
63        return response if limit.zero? || location.to_s.empty?
64
65        target = uri.merge(location)
66        get_following_redirects(
67          client, target, forwardable(headers, uri, target), limit: limit - 1
68        )
69      end
70
71      def forwardable(headers, from, to)
72        return headers if origin(from) == origin(to)
73
74        headers.reject { |name, _| name.to_s.casecmp?('authorization') }
75      end
76
77      def origin(uri)
78        [uri.scheme, uri.host, uri.port]
79      end
80
81      # An unparsed body is reported rather than raised, so the caller can
82      # show it, but it is never a successful result.
83      def result_for(response)
84        Result.new(response.code.to_i, parse(response.body))
85      rescue JSON::ParserError => error
86        Scim::Kit.logger.error(error)
87        Result.new(response.code.to_i, { detail: response.body }, true)
88      end
89
90      def parse(body)
91        return {} if body.nil?
92
93        JSON.parse(body, symbolize_names: true)
94      end
95    end
96  end
97end