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.
- checksums.yaml +7 -0
- data/.editorconfig +9 -0
- data/.overcommit.yml +36 -0
- data/.rspec +3 -0
- data/.rubocop.yml +11 -0
- data/.tool-versions +1 -0
- data/.yardopts +3 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +34 -0
- data/Gemfile.lock +180 -0
- data/LICENSE.txt +21 -0
- data/README.md +207 -0
- data/Rakefile +10 -0
- data/http-session.gemspec +37 -0
- data/lib/http/session/cache/cache_control.rb +196 -0
- data/lib/http/session/cache/entry.rb +71 -0
- data/lib/http/session/cache/status.rb +20 -0
- data/lib/http/session/cache.rb +95 -0
- data/lib/http/session/client/perform.rb +143 -0
- data/lib/http/session/client.rb +157 -0
- data/lib/http/session/configurable.rb +143 -0
- data/lib/http/session/features/auto_inflate.rb +60 -0
- data/lib/http/session/options/cache_option.rb +83 -0
- data/lib/http/session/options/cookies_option.rb +22 -0
- data/lib/http/session/options.rb +80 -0
- data/lib/http/session/request.rb +31 -0
- data/lib/http/session/requestable.rb +87 -0
- data/lib/http/session/response/string_body.rb +20 -0
- data/lib/http/session/response.rb +176 -0
- data/lib/http/session/version.rb +5 -0
- data/lib/http/session.rb +97 -0
- data/lib/http-session/webmock.rb +30 -0
- data/lib/http-session.rb +14 -0
- metadata +100 -0
@@ -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
|