ruby-http-session 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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