ruby-http-session 1.0.1

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.
@@ -0,0 +1,143 @@
1
+ class HTTP::Session
2
+ # Provides the same configure API interfaces as HTTP::Client.
3
+ #
4
+ # Mostly borrowed from [http/lib/http/chainable.rb](https://github.com/httprb/http/blob/main/lib/http/chainable.rb)
5
+ module Configurable
6
+ # Set timeout on request.
7
+ #
8
+ # @overload timeout(options = {})
9
+ # Adds per operation timeouts to the request.
10
+ # @param [Hash] options
11
+ # @option options [Float] :read Read timeout
12
+ # @option options [Float] :write Write timeout
13
+ # @option options [Float] :connect Connect timeout
14
+ # @return [Session]
15
+ #
16
+ # @overload timeout(global_timeout)
17
+ # Adds a global timeout to the full request.
18
+ # @param [Numeric] global_timeout
19
+ # @return [Session]
20
+ def timeout(options)
21
+ klass, options =
22
+ case options
23
+ when Numeric then [HTTP::Timeout::Global, {global: options}]
24
+ when Hash then [HTTP::Timeout::PerOperation, options.dup]
25
+ when :null then [HTTP::Timeout::Null, {}]
26
+ else raise ArgumentError, "Use `.timeout(global_timeout_in_seconds)` or `.timeout(connect: x, write: y, read: z)`."
27
+ end
28
+
29
+ %i[global read write connect].each do |k|
30
+ next unless options.key? k
31
+ options["#{k}_timeout".to_sym] = options.delete k
32
+ end
33
+
34
+ branch default_options.merge(
35
+ timeout_class: klass,
36
+ timeout_options: options
37
+ )
38
+ end
39
+
40
+ # Make a request through an HTTP proxy.
41
+ #
42
+ # @param [Array] proxy
43
+ # @return [Session]
44
+ def via(*proxy)
45
+ proxy_hash = {}
46
+ proxy_hash[:proxy_address] = proxy[0] if proxy[0].is_a?(String)
47
+ proxy_hash[:proxy_port] = proxy[1] if proxy[1].is_a?(Integer)
48
+ proxy_hash[:proxy_username] = proxy[2] if proxy[2].is_a?(String)
49
+ proxy_hash[:proxy_password] = proxy[3] if proxy[3].is_a?(String)
50
+ proxy_hash[:proxy_headers] = proxy[2] if proxy[2].is_a?(Hash)
51
+ proxy_hash[:proxy_headers] = proxy[4] if proxy[4].is_a?(Hash)
52
+ raise ArgumentError, "invalid HTTP proxy: #{proxy_hash}" unless (2..5).cover?(proxy_hash.keys.size)
53
+
54
+ branch default_options.with_proxy(proxy_hash)
55
+ end
56
+ alias_method :through, :via
57
+
58
+ # Make client follow redirects.
59
+ #
60
+ # @param options
61
+ # @return [Session]
62
+ def follow(options = {})
63
+ branch default_options.with_follow options
64
+ end
65
+
66
+ # Make a request with the given headers.
67
+ #
68
+ # @param headers
69
+ # @return [Session]
70
+ def headers(headers)
71
+ branch default_options.with_headers(headers)
72
+ end
73
+
74
+ # Make a request with the given cookies.
75
+ #
76
+ # @param cookies
77
+ # @return [Session]
78
+ def cookies(cookies)
79
+ branch default_options.with_cookies(cookies)
80
+ end
81
+
82
+ # Force a specific encoding for response body.
83
+ #
84
+ # @param encoding
85
+ # @return [Session]
86
+ def encoding(encoding)
87
+ branch default_options.with_encoding(encoding)
88
+ end
89
+
90
+ # Accept the given MIME type(s).
91
+ #
92
+ # @param type
93
+ # @return [Session]
94
+ def accept(type)
95
+ headers HTTP::Headers::ACCEPT => HTTP::MimeType.normalize(type)
96
+ end
97
+
98
+ # Make a request with the given Authorization header.
99
+ #
100
+ # @param [#to_s] value Authorization header value
101
+ # @return [Session]
102
+ def auth(value)
103
+ headers HTTP::Headers::AUTHORIZATION => value.to_s
104
+ end
105
+
106
+ # Make a request with the given Basic authorization header.
107
+ #
108
+ # @see https://datatracker.ietf.org/doc/html/rfc2617
109
+ # @param [#fetch] opts
110
+ # @option opts [#to_s] :user
111
+ # @option opts [#to_s] :pass
112
+ # @return [Session]
113
+ def basic_auth(opts)
114
+ user = opts.fetch(:user)
115
+ pass = opts.fetch(:pass)
116
+ creds = "#{user}:#{pass}"
117
+
118
+ auth("Basic #{Base64.strict_encode64(creds)}")
119
+ end
120
+
121
+ # Set TCP_NODELAY on the socket.
122
+ #
123
+ # @return [Session]
124
+ def nodelay
125
+ branch default_options.with_nodelay(true)
126
+ end
127
+
128
+ # Turn on given features.
129
+ #
130
+ # @return [Session]
131
+ def use(*features)
132
+ branch default_options.with_features(features)
133
+ end
134
+
135
+ private
136
+
137
+ # :nodoc:
138
+ def branch(default_options)
139
+ raise FrozenError, "can't modify frozen #{self.class.name}" if frozen?
140
+ self
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,60 @@
1
+ require "set"
2
+
3
+ class HTTP::Session
4
+ module Features
5
+ class AutoInflate < HTTP::Feature
6
+ def initialize(br: false)
7
+ load_dependencies if br
8
+
9
+ @supported_encoding = Set.new(%w[deflate gzip x-gzip])
10
+ @supported_encoding.add("br") if br
11
+ @supported_encoding.freeze
12
+ end
13
+
14
+ def wrap_response(response)
15
+ content_encoding = response.headers.get(HTTP::Headers::CONTENT_ENCODING).first
16
+ return response unless content_encoding && @supported_encoding.include?(content_encoding)
17
+
18
+ content =
19
+ case content_encoding
20
+ when "br" then brotli_inflate(response.body)
21
+ else inflate(response.body)
22
+ end
23
+ response.headers.delete(HTTP::Headers::CONTENT_ENCODING)
24
+ response.headers[HTTP::Headers::CONTENT_LENGTH] = content.length
25
+
26
+ options = {
27
+ status: response.status,
28
+ version: response.version,
29
+ headers: response.headers,
30
+ proxy_headers: response.proxy_headers,
31
+ body: HTTP::Session::Response::StringBody.new(content),
32
+ request: response.request
33
+ }
34
+ HTTP::Response.new(options)
35
+ end
36
+
37
+ private
38
+
39
+ def load_dependencies
40
+ require "brotli"
41
+ rescue LoadError
42
+ raise LoadError,
43
+ "Specified 'brotli' for inflate, but the gem is not loaded. Add `gem 'brotli'` to your Gemfile."
44
+ end
45
+
46
+ def brotli_inflate(body)
47
+ Brotli.inflate(body)
48
+ end
49
+
50
+ def inflate(body)
51
+ zstream = Zlib::Inflate.new(32 + Zlib::MAX_WBITS)
52
+ zstream.inflate(body)
53
+ ensure
54
+ zstream.close
55
+ end
56
+
57
+ HTTP::Options.register_feature(:hsf_auto_inflate, self)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,83 @@
1
+ class HTTP::Session
2
+ class Options
3
+ class CacheOption
4
+ # @!attribute [r] store
5
+ # @return [ActiveSupport::Cache::Store]
6
+ attr_reader :store
7
+
8
+ # @param [Hash] options
9
+ # @option options [Boolean] :private set true if it is a private cache
10
+ # @option options [Boolean] :shared set true if it is a shared cache
11
+ # @option options [ActiveSupport::Cache::Store] :store
12
+ def initialize(options)
13
+ options =
14
+ case options
15
+ when nil, false then {enabled: false}
16
+ when true then {enabled: true}
17
+ else options
18
+ end
19
+
20
+ # Enabled / Disabled
21
+ @enabled = options.fetch(:enabled, true)
22
+
23
+ # Shared Cache / Private Cache
24
+ @shared =
25
+ if options.key?(:shared)
26
+ raise ArgumentError, ":shared and :private cannot be used at the same time" if options.key?(:private)
27
+ !!options[:shared]
28
+ elsif options.key?(:private)
29
+ !options[:private]
30
+ else
31
+ true
32
+ end
33
+
34
+ # Cache Store
35
+ @store =
36
+ if @enabled
37
+ store = options[:store]
38
+ if store.respond_to?(:read) && store.respond_to?(:write)
39
+ store
40
+ else
41
+ lookup_store(store)
42
+ end
43
+ end
44
+ end
45
+
46
+ # Indicates whether or not the session cache feature is enabled.
47
+ def enabled?
48
+ @enabled
49
+ end
50
+
51
+ # True when it is a shared cache.
52
+ #
53
+ # Shared Cache that exists between the origin server and clients (e.g. Proxy, CDN).
54
+ # It stores a single response and reuses it with multiple users
55
+ def shared_cache?
56
+ @shared
57
+ end
58
+
59
+ # True when it is a private cache.
60
+ #
61
+ # Private Cache that exists in the client. It is also called local cache or browser cache.
62
+ # It can store and reuse personalized content for a single user.
63
+ def private_cache?
64
+ !shared_cache?
65
+ end
66
+
67
+ private
68
+
69
+ def lookup_store(store)
70
+ load_dependencies
71
+ ActiveSupport::Cache.lookup_store(store)
72
+ end
73
+
74
+ def load_dependencies
75
+ require "active_support/cache"
76
+ require "active_support/notifications"
77
+ rescue LoadError
78
+ raise LoadError,
79
+ "Specified 'active_support' for caching, but the gem is not loaded. Add `gem 'active_support'` to your Gemfile."
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,22 @@
1
+ class HTTP::Session
2
+ class Options
3
+ class CookiesOption
4
+ # @param [Hash] options
5
+ def initialize(options)
6
+ options =
7
+ case options
8
+ when nil, false then {enabled: false}
9
+ when true then {enabled: true}
10
+ else options
11
+ end
12
+
13
+ @enabled = options.fetch(:enabled, true)
14
+ end
15
+
16
+ # Indicates whether or not the session cookie feature is enabled.
17
+ def enabled?
18
+ @enabled
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,80 @@
1
+ class HTTP::Session
2
+ class Options
3
+ # @!attribute [r] cookies
4
+ # @return [CookiesOption]
5
+ attr_reader :cookies
6
+
7
+ # @!attribute [r] cache
8
+ # @return [CacheOption]
9
+ attr_reader :cache
10
+
11
+ # @!attribute [r] http
12
+ # @return [HTTP::Options]
13
+ attr_reader :http
14
+
15
+ # @param [Hash] options
16
+ def initialize(options)
17
+ @cookies = HTTP::Session::Options::CookiesOption.new(options.fetch(:cookies, false))
18
+ @cache = HTTP::Session::Options::CacheOption.new(options.fetch(:cache, false))
19
+ @http = HTTP::Options.new(
20
+ options.fetch(:http, {}).slice(
21
+ :cookies,
22
+ :encoding,
23
+ :features,
24
+ :follow,
25
+ :headers,
26
+ :keep_alive_timeout,
27
+ :nodelay,
28
+ :proxy,
29
+ :response,
30
+ :ssl,
31
+ :ssl_socket_class,
32
+ :socket_class,
33
+ :timeout_class,
34
+ :timeout_options
35
+ )
36
+ )
37
+ end
38
+
39
+ # @return [Options]
40
+ def merge(other)
41
+ tap { @http = @http.merge(other) }
42
+ end
43
+
44
+ # @return [Options]
45
+ def with_cookies(cookies)
46
+ tap { @http = @http.with_cookies(cookies) }
47
+ end
48
+
49
+ # @return [Options]
50
+ def with_encoding(encoding)
51
+ tap { @http = @http.with_encoding(encoding) }
52
+ end
53
+
54
+ # @return [Options]
55
+ def with_features(features)
56
+ raise ArgumentError, "feature :auto_inflate is not supported, use :hsf_auto_inflate instead" if features.include?(:auto_inflate)
57
+ tap { @http = @http.with_features(features) }
58
+ end
59
+
60
+ # @return [Options]
61
+ def with_follow(follow)
62
+ tap { @http = @http.with_follow(follow) }
63
+ end
64
+
65
+ # @return [Options]
66
+ def with_headers(headers)
67
+ tap { @http = @http.with_headers(headers) }
68
+ end
69
+
70
+ # @return [Options]
71
+ def with_nodelay(nodelay)
72
+ tap { @http = @http.with_nodelay(nodelay) }
73
+ end
74
+
75
+ # @return [Options]
76
+ def with_proxy(proxy)
77
+ tap { @http = @http.with_proxy(proxy) }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,31 @@
1
+ class HTTP::Session
2
+ # Provides access to the HTTP request.
3
+ #
4
+ # Mostly borrowed from [rack-cache/lib/rack/cache/request.rb](https://github.com/rack/rack-cache/blob/main/lib/rack/cache/request.rb)
5
+ class Request < SimpleDelegator
6
+ class << self
7
+ def new(*args)
8
+ args[0].is_a?(self) ? args[0] : super
9
+ end
10
+ end
11
+
12
+ # A CacheControl instance based on the request's cache-control header.
13
+ #
14
+ # @return [Cache::CacheControl]
15
+ def cache_control
16
+ @cache_control ||= HTTP::Session::Cache::CacheControl.new(headers[HTTP::Headers::CACHE_CONTROL])
17
+ end
18
+
19
+ # True when the cache-control/no-cache directive is present.
20
+ def no_cache?
21
+ cache_control.no_cache?
22
+ end
23
+
24
+ # Determine if the request is worth caching under any circumstance.
25
+ def cacheable?
26
+ return false if verb != :get && verb != :head
27
+ return false if cache_control.no_store?
28
+ true
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,87 @@
1
+ class HTTP::Session
2
+ # Provides the same request API interfaces as HTTP::Client.
3
+ #
4
+ # Mostly borrowed from [http/lib/http/chainable.rb](https://github.com/httprb/http/blob/main/lib/http/chainable.rb)
5
+ module Requestable
6
+ # Request a get sans response body.
7
+ #
8
+ # @param uri
9
+ # @option [Hash] options
10
+ # @return [Response]
11
+ def head(uri, options = {})
12
+ request :head, uri, options
13
+ end
14
+
15
+ # Get a resource.
16
+ #
17
+ # @param uri
18
+ # @option [Hash] options
19
+ # @return [Response]
20
+ def get(uri, options = {})
21
+ request :get, uri, options
22
+ end
23
+
24
+ # Post to a resource.
25
+ #
26
+ # @param uri
27
+ # @option [Hash] options
28
+ # @return [Response]
29
+ def post(uri, options = {})
30
+ request :post, uri, options
31
+ end
32
+
33
+ # Put to a resource.
34
+ #
35
+ # @param uri
36
+ # @option [Hash] options
37
+ # @return [Response]
38
+ def put(uri, options = {})
39
+ request :put, uri, options
40
+ end
41
+
42
+ # Delete a resource.
43
+ #
44
+ # @param uri
45
+ # @option [Hash] options
46
+ # @return [Response]
47
+ def delete(uri, options = {})
48
+ request :delete, uri, options
49
+ end
50
+
51
+ # Echo the request back to the client.
52
+ #
53
+ # @param uri
54
+ # @option [Hash] options
55
+ # @return [Response]
56
+ def trace(uri, options = {})
57
+ request :trace, uri, options
58
+ end
59
+
60
+ # Return the methods supported on the given URI.
61
+ #
62
+ # @param uri
63
+ # @option [Hash] options
64
+ # @return [Response]
65
+ def options(uri, options = {})
66
+ request :options, uri, options
67
+ end
68
+
69
+ # Convert to a transparent TCP/IP tunnel.
70
+ #
71
+ # @param uri
72
+ # @option [Hash] options
73
+ # @return [Response]
74
+ def connect(uri, options = {})
75
+ request :connect, uri, options
76
+ end
77
+
78
+ # Apply partial modifications to a resource.
79
+ #
80
+ # @param uri
81
+ # @option [Hash] options
82
+ # @return [Response]
83
+ def patch(uri, options = {})
84
+ request :patch, uri, options
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,20 @@
1
+ require "forwardable"
2
+
3
+ class HTTP::Session
4
+ class Response < SimpleDelegator
5
+ class StringBody
6
+ extend Forwardable
7
+
8
+ def_delegator :to_s, :empty?
9
+
10
+ def initialize(contents)
11
+ @contents = contents
12
+ end
13
+
14
+ def to_s
15
+ @contents
16
+ end
17
+ alias_method :to_str, :to_s
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,176 @@
1
+ class HTTP::Session
2
+ # Provides access to the HTTP response.
3
+ #
4
+ # Mostly borrowed from [rack-cache/lib/rack/cache/response.rb](https://github.com/rack/rack-cache/blob/main/lib/rack/cache/response.rb)
5
+ class Response < SimpleDelegator
6
+ class << self
7
+ def new(*args)
8
+ args[0].is_a?(self) ? args[0] : super
9
+ end
10
+ end
11
+
12
+ # Status codes of responses that MAY be stored by a cache or used in reply
13
+ # to a subsequent request.
14
+ #
15
+ # https://datatracker.ietf.org/doc/html/rfc9110#section-15.1
16
+ CACHEABLE_RESPONSE_CODES = [
17
+ 200, # OK
18
+ 203, # Non-Authoritative Information
19
+ 204, # No Content
20
+ 206, # Partial Content
21
+ 300, # Multiple Choices
22
+ 301, # Moved Permanently
23
+ 308, # Permanent Redirect
24
+ 404, # Not Found
25
+ 405, # Method Not Allowed
26
+ 410, # Gone
27
+ 414, # URI Too Long
28
+ 501 # Not Implemented
29
+ ].to_set
30
+
31
+ # @!attribute [rw] history
32
+ # @return [Array<Response>] a list of response objects holding the history of the redirection
33
+ attr_accessor :history
34
+
35
+ # Returns a new instance of Response.
36
+ def initialize(*args)
37
+ super
38
+
39
+ _hs_ensure_header_date
40
+ @history = []
41
+ end
42
+
43
+ # Determine if the response is served from cache.
44
+ def from_cache?
45
+ v = headers[HTTP::Session::Cache::Status::HEADER_NAME]
46
+ HTTP::Session::Cache::Status.HIT?(v)
47
+ end
48
+
49
+ # A CacheControl instance based on the response's cache-control header.
50
+ #
51
+ # @return [Cache::CacheControl]
52
+ def cache_control
53
+ @cache_control ||= HTTP::Session::Cache::CacheControl.new(headers[HTTP::Headers::CACHE_CONTROL])
54
+ end
55
+
56
+ # True when the cache-control/no-cache directive is present.
57
+ def no_cache?
58
+ cache_control.no_cache?
59
+ end
60
+
61
+ # Determine if the response is worth caching under any circumstance.
62
+ #
63
+ # https://datatracker.ietf.org/doc/html/rfc9111#section-3
64
+ def cacheable?(shared:, req:)
65
+ # the response status code is final (see Section 15 of [HTTP])
66
+ return false unless CACHEABLE_RESPONSE_CODES.include?(status)
67
+
68
+ # the no-store cache directive is not present in the response (see Section 5.2.2.5)
69
+ return false if cache_control.no_store?
70
+
71
+ # if the cache is shared
72
+ if shared
73
+ # the private response directive is either not present or allows a shared cache
74
+ # to store a modified response; see Section 5.2.2.7)
75
+ return false if cache_control.private?
76
+
77
+ # the Authorization header field is not present in the request (see Section 11.6.2
78
+ # of [HTTP]) or a response directive is present that explicitly allows shared
79
+ # caching (see Section 3.5)
80
+ return false if req.headers[HTTP::Headers::AUTHORIZATION] &&
81
+ (!cache_control.public? && !cache_control.shared_max_age)
82
+ end
83
+
84
+ # responses with neither a freshness lifetime (expires, max-age) nor cache validator
85
+ # (last-modified, etag) are considered uncacheable
86
+ validateable? || fresh?(shared: shared)
87
+ end
88
+
89
+ # Determine if the response includes headers that can be used to validate
90
+ # the response with the origin using a conditional GET request.
91
+ def validateable?
92
+ headers.include?(HTTP::Headers::LAST_MODIFIED) || headers.include?(HTTP::Headers::ETAG)
93
+ end
94
+
95
+ # Determine if the response is "fresh". Fresh responses may be served from
96
+ # cache without any interaction with the origin. A response is considered
97
+ # fresh when it includes a cache-control/max-age indicator or Expiration
98
+ # header and the calculated age is less than the freshness lifetime.
99
+ def fresh?(shared:)
100
+ ttl(shared: shared) && ttl(shared: shared) > 0
101
+ end
102
+
103
+ # The response's time-to-live in seconds, or nil when no freshness
104
+ # information is present in the response. When the responses #ttl
105
+ # is <= 0, the response may not be served from cache without first
106
+ # revalidating with the origin.
107
+ #
108
+ # @return [Numeric]
109
+ def ttl(shared:)
110
+ max_age(shared: shared) - age if max_age(shared: shared)
111
+ end
112
+
113
+ # The number of seconds after the time specified in the response's Date
114
+ # header when the the response should no longer be considered fresh. First
115
+ # check for a r-maxage directive, then a s-maxage directive, then a max-age
116
+ # directive, and then fall back on an expires header; return nil when no
117
+ # maximum age can be established.
118
+ #
119
+ # @return [Numeric]
120
+ def max_age(shared:)
121
+ (shared && cache_control.shared_max_age) ||
122
+ cache_control.max_age ||
123
+ (expires && (expires - date))
124
+ end
125
+
126
+ # The value of the expires header as a Time object.
127
+ #
128
+ # @return [Time]
129
+ def expires
130
+ headers[HTTP::Headers::EXPIRES] && Time.httpdate(headers[HTTP::Headers::EXPIRES])
131
+ rescue
132
+ nil
133
+ end
134
+
135
+ # The date of the response.
136
+ #
137
+ # @return [Time]
138
+ def date
139
+ Time.httpdate(headers[HTTP::Headers::DATE])
140
+ end
141
+
142
+ # The age of the response.
143
+ #
144
+ # @return [Numeric]
145
+ def age
146
+ (headers[HTTP::Headers::AGE] || [(now - date).to_i, 0].max).to_i
147
+ end
148
+
149
+ # The literal value of ETag HTTP header or nil if no etag is specified.
150
+ def etag
151
+ headers[HTTP::Headers::ETAG]
152
+ end
153
+
154
+ # The String value of the Last-Modified header exactly as it appears
155
+ # in the response (i.e., no date parsing / conversion is performed).
156
+ def last_modified
157
+ headers[HTTP::Headers::LAST_MODIFIED]
158
+ end
159
+
160
+ # The current time.
161
+ #
162
+ # @return [Time]
163
+ def now
164
+ Time.now
165
+ end
166
+
167
+ private
168
+
169
+ # When no Date header is present or is unparseable, set the Date header to Time.now.
170
+ def _hs_ensure_header_date
171
+ date
172
+ rescue
173
+ headers[HTTP::Headers::DATE] = now.httpdate
174
+ end
175
+ end
176
+ end