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