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,196 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Cache
|
3
|
+
# Parses a cache-control header and exposes the directives as a Hash.
|
4
|
+
# Directives that do not have values are set to +true+.
|
5
|
+
#
|
6
|
+
# Mostly borrowed from [rack-cache/lib/rack/cache/cache_control.rb](https://github.com/rack/rack-cache/blob/main/lib/rack/cache/cache_control.rb)
|
7
|
+
class CacheControl < Hash
|
8
|
+
def initialize(value = nil)
|
9
|
+
parse(value)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Indicates that the response MAY be cached by any cache, even if it
|
13
|
+
# would normally be non-cacheable or cacheable only within a non-
|
14
|
+
# shared cache.
|
15
|
+
#
|
16
|
+
# A response may be considered public without this directive if the
|
17
|
+
# private directive is not set and the request does not include an
|
18
|
+
# Authorization header.
|
19
|
+
def public?
|
20
|
+
self["public"]
|
21
|
+
end
|
22
|
+
|
23
|
+
# Indicates that all or part of the response message is intended for
|
24
|
+
# a single user and MUST NOT be cached by a shared cache. This
|
25
|
+
# allows an origin server to state that the specified parts of the
|
26
|
+
# response are intended for only one user and are not a valid
|
27
|
+
# response for requests by other users. A private (non-shared) cache
|
28
|
+
# MAY cache the response.
|
29
|
+
#
|
30
|
+
# Note: This usage of the word private only controls where the
|
31
|
+
# response may be cached, and cannot ensure the privacy of the
|
32
|
+
# message content.
|
33
|
+
def private?
|
34
|
+
self["private"]
|
35
|
+
end
|
36
|
+
|
37
|
+
# When set in a response, a cache MUST NOT use the response to satisfy a
|
38
|
+
# subsequent request without successful revalidation with the origin
|
39
|
+
# server. This allows an origin server to prevent caching even by caches
|
40
|
+
# that have been configured to return stale responses to client requests.
|
41
|
+
#
|
42
|
+
# Note that this does not necessary imply that the response may not be
|
43
|
+
# stored by the cache, only that the cache cannot serve it without first
|
44
|
+
# making a conditional GET request with the origin server.
|
45
|
+
#
|
46
|
+
# When set in a request, the server MUST NOT use a cached copy for its
|
47
|
+
# response. This has quite different semantics compared to the no-cache
|
48
|
+
# directive on responses. When the client specifies no-cache, it causes
|
49
|
+
# an end-to-end reload, forcing each cache to update their cached copies.
|
50
|
+
def no_cache?
|
51
|
+
self["no-cache"]
|
52
|
+
end
|
53
|
+
|
54
|
+
# Indicates that the response MUST NOT be stored under any circumstances.
|
55
|
+
#
|
56
|
+
# The purpose of the no-store directive is to prevent the
|
57
|
+
# inadvertent release or retention of sensitive information (for
|
58
|
+
# example, on backup tapes). The no-store directive applies to the
|
59
|
+
# entire message, and MAY be sent either in a response or in a
|
60
|
+
# request. If sent in a request, a cache MUST NOT store any part of
|
61
|
+
# either this request or any response to it. If sent in a response,
|
62
|
+
# a cache MUST NOT store any part of either this response or the
|
63
|
+
# request that elicited it. This directive applies to both non-
|
64
|
+
# shared and shared caches. "MUST NOT store" in this context means
|
65
|
+
# that the cache MUST NOT intentionally store the information in
|
66
|
+
# non-volatile storage, and MUST make a best-effort attempt to
|
67
|
+
# remove the information from volatile storage as promptly as
|
68
|
+
# possible after forwarding it.
|
69
|
+
#
|
70
|
+
# The purpose of this directive is to meet the stated requirements
|
71
|
+
# of certain users and service authors who are concerned about
|
72
|
+
# accidental releases of information via unanticipated accesses to
|
73
|
+
# cache data structures. While the use of this directive might
|
74
|
+
# improve privacy in some cases, we caution that it is NOT in any
|
75
|
+
# way a reliable or sufficient mechanism for ensuring privacy. In
|
76
|
+
# particular, malicious or compromised caches might not recognize or
|
77
|
+
# obey this directive, and communications networks might be
|
78
|
+
# vulnerable to eavesdropping.
|
79
|
+
def no_store?
|
80
|
+
self["no-store"]
|
81
|
+
end
|
82
|
+
|
83
|
+
# The expiration time of an entity MAY be specified by the origin
|
84
|
+
# server using the expires header (see section 14.21). Alternatively,
|
85
|
+
# it MAY be specified using the max-age directive in a response. When
|
86
|
+
# the max-age cache-control directive is present in a cached response,
|
87
|
+
# the response is stale if its current age is greater than the age
|
88
|
+
# value given (in seconds) at the time of a new request for that
|
89
|
+
# resource. The max-age directive on a response implies that the
|
90
|
+
# response is cacheable (i.e., "public") unless some other, more
|
91
|
+
# restrictive cache directive is also present.
|
92
|
+
#
|
93
|
+
# If a response includes both an expires header and a max-age
|
94
|
+
# directive, the max-age directive overrides the expires header, even
|
95
|
+
# if the expires header is more restrictive. This rule allows an origin
|
96
|
+
# server to provide, for a given response, a longer expiration time to
|
97
|
+
# an HTTP/1.1 (or later) cache than to an HTTP/1.0 cache. This might be
|
98
|
+
# useful if certain HTTP/1.0 caches improperly calculate ages or
|
99
|
+
# expiration times, perhaps due to desynchronized clocks.
|
100
|
+
#
|
101
|
+
# Many HTTP/1.0 cache implementations will treat an expires value that
|
102
|
+
# is less than or equal to the response Date value as being equivalent
|
103
|
+
# to the cache-control response directive "no-cache". If an HTTP/1.1
|
104
|
+
# cache receives such a response, and the response does not include a
|
105
|
+
# cache-control header field, it SHOULD consider the response to be
|
106
|
+
# non-cacheable in order to retain compatibility with HTTP/1.0 servers.
|
107
|
+
#
|
108
|
+
# When the max-age directive is included in the request, it indicates
|
109
|
+
# that the client is willing to accept a response whose age is no
|
110
|
+
# greater than the specified time in seconds.
|
111
|
+
def max_age
|
112
|
+
self["max-age"].to_i if key?("max-age")
|
113
|
+
end
|
114
|
+
|
115
|
+
# If a response includes an s-maxage directive, then for a shared
|
116
|
+
# cache (but not for a private cache), the maximum age specified by
|
117
|
+
# this directive overrides the maximum age specified by either the
|
118
|
+
# max-age directive or the expires header. The s-maxage directive
|
119
|
+
# also implies the semantics of the proxy-revalidate directive. i.e.,
|
120
|
+
# that the shared cache must not use the entry after it becomes stale
|
121
|
+
# to respond to a subsequent request without first revalidating it with
|
122
|
+
# the origin server. The s-maxage directive is always ignored by a
|
123
|
+
# private cache.
|
124
|
+
def shared_max_age
|
125
|
+
self["s-maxage"].to_i if key?("s-maxage")
|
126
|
+
end
|
127
|
+
alias_method :s_maxage, :shared_max_age
|
128
|
+
|
129
|
+
# Because a cache MAY be configured to ignore a server's specified
|
130
|
+
# expiration time, and because a client request MAY include a max-
|
131
|
+
# stale directive (which has a similar effect), the protocol also
|
132
|
+
# includes a mechanism for the origin server to require revalidation
|
133
|
+
# of a cache entry on any subsequent use. When the must-revalidate
|
134
|
+
# directive is present in a response received by a cache, that cache
|
135
|
+
# MUST NOT use the entry after it becomes stale to respond to a
|
136
|
+
# subsequent request without first revalidating it with the origin
|
137
|
+
# server. (I.e., the cache MUST do an end-to-end revalidation every
|
138
|
+
# time, if, based solely on the origin server's expires or max-age
|
139
|
+
# value, the cached response is stale.)
|
140
|
+
#
|
141
|
+
# The must-revalidate directive is necessary to support reliable
|
142
|
+
# operation for certain protocol features. In all circumstances an
|
143
|
+
# HTTP/1.1 cache MUST obey the must-revalidate directive; in
|
144
|
+
# particular, if the cache cannot reach the origin server for any
|
145
|
+
# reason, it MUST generate a 504 (Gateway Timeout) response.
|
146
|
+
#
|
147
|
+
# Servers SHOULD send the must-revalidate directive if and only if
|
148
|
+
# failure to revalidate a request on the entity could result in
|
149
|
+
# incorrect operation, such as a silently unexecuted financial
|
150
|
+
# transaction. Recipients MUST NOT take any automated action that
|
151
|
+
# violates this directive, and MUST NOT automatically provide an
|
152
|
+
# unvalidated copy of the entity if revalidation fails.
|
153
|
+
def must_revalidate?
|
154
|
+
self["must-revalidate"]
|
155
|
+
end
|
156
|
+
|
157
|
+
# The proxy-revalidate directive has the same meaning as the must-
|
158
|
+
# revalidate directive, except that it does not apply to non-shared
|
159
|
+
# user agent caches. It can be used on a response to an
|
160
|
+
# authenticated request to permit the user's cache to store and
|
161
|
+
# later return the response without needing to revalidate it (since
|
162
|
+
# it has already been authenticated once by that user), while still
|
163
|
+
# requiring proxies that service many users to revalidate each time
|
164
|
+
# (in order to make sure that each user has been authenticated).
|
165
|
+
# Note that such authenticated responses also need the public cache
|
166
|
+
# control directive in order to allow them to be cached at all.
|
167
|
+
def proxy_revalidate?
|
168
|
+
self["proxy-revalidate"]
|
169
|
+
end
|
170
|
+
|
171
|
+
def to_s
|
172
|
+
bools, vals = [], []
|
173
|
+
each do |key, value|
|
174
|
+
if value == true
|
175
|
+
bools << key
|
176
|
+
elsif value
|
177
|
+
vals << "#{key}=#{value}"
|
178
|
+
end
|
179
|
+
end
|
180
|
+
(bools.sort + vals.sort).join(", ")
|
181
|
+
end
|
182
|
+
|
183
|
+
private
|
184
|
+
|
185
|
+
def parse(value)
|
186
|
+
return if value.nil? || value.empty?
|
187
|
+
value.delete(" ").split(",").each do |part|
|
188
|
+
next if part.empty?
|
189
|
+
name, value = part.split("=", 2)
|
190
|
+
self[name.downcase] = (value || true) unless name.empty?
|
191
|
+
end
|
192
|
+
self
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Cache
|
3
|
+
class Entry
|
4
|
+
class << self
|
5
|
+
# Deserializes from a JSON primitive type.
|
6
|
+
def deserialize(h)
|
7
|
+
req = h[:req]
|
8
|
+
req = HTTP::Session::Request.new(HTTP::Request.new(req))
|
9
|
+
|
10
|
+
res = h[:res]
|
11
|
+
res[:request] = req
|
12
|
+
res[:body] = HTTP::Session::Response::StringBody.new(res[:body])
|
13
|
+
res = HTTP::Session::Response.new(HTTP::Response.new(res))
|
14
|
+
|
15
|
+
new(req, res)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# @!attribute [r] request
|
20
|
+
# @return [Request]
|
21
|
+
attr_reader :request
|
22
|
+
|
23
|
+
# @!attribute [r] response
|
24
|
+
# @return [Response]
|
25
|
+
attr_reader :response
|
26
|
+
|
27
|
+
# Returns a new instance of Entry.
|
28
|
+
def initialize(req, res)
|
29
|
+
@request = req
|
30
|
+
@response = res
|
31
|
+
end
|
32
|
+
|
33
|
+
# @param [Request] req │
|
34
|
+
# @return [Response]
|
35
|
+
def to_response(req)
|
36
|
+
h = serialize_response
|
37
|
+
h[:request] = req
|
38
|
+
h[:body] = HTTP::Session::Response::StringBody.new(h[:body])
|
39
|
+
HTTP::Session::Response.new(HTTP::Response.new(h))
|
40
|
+
end
|
41
|
+
|
42
|
+
# Serializes to a JSON primitive type.
|
43
|
+
def serialize
|
44
|
+
{
|
45
|
+
req: serialize_request,
|
46
|
+
res: serialize_response
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def serialize_request
|
53
|
+
{
|
54
|
+
verb: @request.verb,
|
55
|
+
uri: @request.uri.to_s,
|
56
|
+
headers: @request.headers.to_h
|
57
|
+
}
|
58
|
+
end
|
59
|
+
|
60
|
+
def serialize_response
|
61
|
+
{
|
62
|
+
status: @response.status.code,
|
63
|
+
version: @response.version,
|
64
|
+
headers: @response.headers.to_h,
|
65
|
+
proxy_headers: @response.proxy_headers.to_h,
|
66
|
+
body: @response.body.to_s
|
67
|
+
}
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Cache
|
3
|
+
class Status
|
4
|
+
# rubocop:disable Layout/ExtraSpacing
|
5
|
+
HEADER_NAME = "X-Httprb-Cache-Status"
|
6
|
+
HIT = "HIT" # found in cache
|
7
|
+
REVALIDATED = "REVALIDATED" # found in cache but stale, revalidated success
|
8
|
+
EXPIRED = "EXPIRED" # found in cache but stale, revalidated failure, served from the origin server
|
9
|
+
MISS = "MISS" # not found in cache, served from the origin server
|
10
|
+
UNCACHEABLE = "UNCACHEABLE" # the request can not use cached response
|
11
|
+
# rubocop:enable Layout/ExtraSpacing
|
12
|
+
|
13
|
+
class << self
|
14
|
+
def HIT?(v)
|
15
|
+
v == HIT || v == REVALIDATED
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Cache
|
3
|
+
include MonitorMixin
|
4
|
+
|
5
|
+
# @param [Options::CacheOption] options
|
6
|
+
def initialize(options)
|
7
|
+
super()
|
8
|
+
|
9
|
+
@options = options
|
10
|
+
end
|
11
|
+
|
12
|
+
# Read an entry from cache.
|
13
|
+
#
|
14
|
+
# @param [Request] req
|
15
|
+
# @return [Entry]
|
16
|
+
def read(req)
|
17
|
+
synchronize do
|
18
|
+
key = cache_key_for(req)
|
19
|
+
entries = read_entries(key)
|
20
|
+
entries.find { |e| entry_matched?(e, req) }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Write an entry to cache.
|
25
|
+
#
|
26
|
+
# @param [Request] req
|
27
|
+
# @param [Response] res
|
28
|
+
# @return [void]
|
29
|
+
def write(req, res)
|
30
|
+
synchronize do
|
31
|
+
key = cache_key_for(req)
|
32
|
+
entries = read_entries(key)
|
33
|
+
entries = entries.reject { |e| entry_matched?(e, req) }
|
34
|
+
entry = HTTP::Session::Cache::Entry.new(req, res)
|
35
|
+
entries << entry
|
36
|
+
write_entries(key, entries)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# True when it is a shared cache.
|
41
|
+
def shared?
|
42
|
+
@options.shared_cache?
|
43
|
+
end
|
44
|
+
|
45
|
+
# True when it is a private cache.
|
46
|
+
def private?
|
47
|
+
@options.private_cache?
|
48
|
+
end
|
49
|
+
|
50
|
+
# @!visibility private
|
51
|
+
def store
|
52
|
+
@options.store
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def entry_matched?(entry, req)
|
58
|
+
entry_matched_by_verb?(entry, req) &&
|
59
|
+
entry_matched_by_headers?(entry, req)
|
60
|
+
end
|
61
|
+
|
62
|
+
def entry_matched_by_verb?(entry, req)
|
63
|
+
entry.request.verb == req.verb
|
64
|
+
end
|
65
|
+
|
66
|
+
def entry_matched_by_headers?(entry, req)
|
67
|
+
vary = entry.response.headers[HTTP::Headers::VARY]
|
68
|
+
return true if vary.nil? || vary == ""
|
69
|
+
|
70
|
+
vary != "*" && vary.split(",").map(&:strip).all? do |name|
|
71
|
+
entry.request.headers[name] == req.headers[name]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def read_entries(key)
|
76
|
+
entrie = store.read(key) || []
|
77
|
+
entrie.map do |e|
|
78
|
+
Entry.deserialize(e)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def write_entries(key, entries)
|
83
|
+
entries = entries.map do |e|
|
84
|
+
e = e.serialize
|
85
|
+
e[:res][:headers].delete(HTTP::Headers::AGE)
|
86
|
+
e
|
87
|
+
end
|
88
|
+
store.write(key, entries)
|
89
|
+
end
|
90
|
+
|
91
|
+
def cache_key_for(req)
|
92
|
+
Digest::SHA256.hexdigest(req.uri)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Client
|
3
|
+
# Make an HTTP request without any HTTP::Features.
|
4
|
+
#
|
5
|
+
# Mostly borrowed from [http/lib/http/client.rb](https://github.com/httprb/http/blob/main/lib/http/client.rb)
|
6
|
+
module Perform
|
7
|
+
HTTP_OR_HTTPS_RE = %r{^https?://}i
|
8
|
+
|
9
|
+
attr_reader :default_options
|
10
|
+
|
11
|
+
def httprb_initialize(default_options)
|
12
|
+
@default_options = HTTP::Options.new(default_options)
|
13
|
+
@connection = nil
|
14
|
+
@state = :clean
|
15
|
+
end
|
16
|
+
|
17
|
+
def httprb_perform(req, options)
|
18
|
+
verify_connection!(req.uri)
|
19
|
+
|
20
|
+
@state = :dirty
|
21
|
+
begin
|
22
|
+
@connection ||= HTTP::Connection.new(req, options)
|
23
|
+
unless @connection.failed_proxy_connect?
|
24
|
+
@connection.send_request(req)
|
25
|
+
@connection.read_headers!
|
26
|
+
end
|
27
|
+
rescue HTTP::Error => e
|
28
|
+
options.features.each_value do |feature|
|
29
|
+
feature.on_error(req, e)
|
30
|
+
end
|
31
|
+
raise
|
32
|
+
end
|
33
|
+
|
34
|
+
res = build_response(req, options)
|
35
|
+
@connection.finish_response if req.verb == :head
|
36
|
+
@state = :clean
|
37
|
+
res
|
38
|
+
rescue
|
39
|
+
close
|
40
|
+
raise
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def verify_connection!(uri)
|
46
|
+
if default_options.persistent? && uri.origin != default_options.persistent
|
47
|
+
raise HTTP::StateError, "Persistence is enabled for #{default_options.persistent}, but we got #{uri.origin}"
|
48
|
+
end
|
49
|
+
return close if @connection && (!@connection.keep_alive? || @connection.expired?)
|
50
|
+
close if @state == :dirty
|
51
|
+
end
|
52
|
+
|
53
|
+
def close
|
54
|
+
@connection&.close
|
55
|
+
@connection = nil
|
56
|
+
@state = :clean
|
57
|
+
end
|
58
|
+
|
59
|
+
def build_response(req, options)
|
60
|
+
res = HTTP::Response.new(
|
61
|
+
status: @connection.status_code,
|
62
|
+
version: @connection.http_version,
|
63
|
+
headers: @connection.headers,
|
64
|
+
proxy_headers: @connection.proxy_response_headers,
|
65
|
+
connection: @connection,
|
66
|
+
encoding: options.encoding,
|
67
|
+
request: req
|
68
|
+
)
|
69
|
+
HTTP::Session::Response.new(res)
|
70
|
+
end
|
71
|
+
|
72
|
+
def wrap_response(res, opts)
|
73
|
+
opts.features.to_a.reverse.to_h.inject(res) do |response, (_name, feature)|
|
74
|
+
response = feature.wrap_response(response)
|
75
|
+
HTTP::Session::Response.new(response)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def build_request(verb, uri, opts = {})
|
80
|
+
opts = @default_options.merge(opts)
|
81
|
+
uri = make_request_uri(uri, opts)
|
82
|
+
headers = make_request_headers(opts)
|
83
|
+
body = make_request_body(opts, headers)
|
84
|
+
req = HTTP::Request.new(
|
85
|
+
verb: verb,
|
86
|
+
uri: uri,
|
87
|
+
uri_normalizer: opts.feature(:normalize_uri)&.normalizer,
|
88
|
+
proxy: opts.proxy,
|
89
|
+
headers: headers,
|
90
|
+
body: body
|
91
|
+
)
|
92
|
+
HTTP::Session::Request.new(req)
|
93
|
+
end
|
94
|
+
|
95
|
+
def wrap_request(req, opts)
|
96
|
+
opts.features.inject(req) do |request, (_name, feature)|
|
97
|
+
request = feature.wrap_request(request)
|
98
|
+
HTTP::Session::Request.new(request)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def make_request_uri(uri, opts)
|
103
|
+
uri = uri.to_s
|
104
|
+
uri = "#{default_options.persistent}#{uri}" if default_options.persistent? && uri !~ HTTP_OR_HTTPS_RE
|
105
|
+
uri = HTTP::URI.parse uri
|
106
|
+
uri.query_values = uri.query_values(Array).to_a.concat(opts.params.to_a) if opts.params && !opts.params.empty?
|
107
|
+
uri.path = "/" if uri.path.empty?
|
108
|
+
uri
|
109
|
+
end
|
110
|
+
|
111
|
+
def make_request_headers(opts)
|
112
|
+
headers = opts.headers
|
113
|
+
headers[HTTP::Headers::CONNECTION] = default_options.persistent? ? HTTP::Connection::KEEP_ALIVE : HTTP::Connection::CLOSE
|
114
|
+
cookies = opts.cookies.values
|
115
|
+
unless cookies.empty?
|
116
|
+
cookies = opts.headers.get(HTTP::Headers::COOKIE).concat(cookies).join("; ")
|
117
|
+
headers[HTTP::Headers::COOKIE] = cookies
|
118
|
+
end
|
119
|
+
headers
|
120
|
+
end
|
121
|
+
|
122
|
+
def make_request_body(opts, headers)
|
123
|
+
if opts.body
|
124
|
+
opts.body
|
125
|
+
elsif opts.form
|
126
|
+
form = make_form_data(opts.form)
|
127
|
+
headers[HTTP::Headers::CONTENT_TYPE] ||= form.content_type
|
128
|
+
form
|
129
|
+
elsif opts.json
|
130
|
+
body = HTTP::MimeType[:json].encode opts.json
|
131
|
+
headers[HTTP::Headers::CONTENT_TYPE] ||= "application/json; charset=#{body.encoding.name.downcase}"
|
132
|
+
body
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def make_form_data(form)
|
137
|
+
return form if form.is_a? HTTP::FormData::Multipart
|
138
|
+
return form if form.is_a? HTTP::FormData::Urlencoded
|
139
|
+
HTTP::FormData.create(form)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Client
|
3
|
+
include HTTP::Session::Client::Perform
|
4
|
+
|
5
|
+
# @param [Hash] default_options
|
6
|
+
# @param [Session] session
|
7
|
+
def initialize(default_options, session)
|
8
|
+
httprb_initialize(default_options)
|
9
|
+
@session = session
|
10
|
+
end
|
11
|
+
|
12
|
+
# @param verb
|
13
|
+
# @param uri
|
14
|
+
# @param [Hash] opts
|
15
|
+
# @return [Response]
|
16
|
+
def request(verb, uri, opts)
|
17
|
+
data = @session.make_http_request_data
|
18
|
+
hist = []
|
19
|
+
|
20
|
+
opts = @default_options.merge(opts)
|
21
|
+
opts = _hs_handle_http_request_options_cookies(opts, data[:cookies])
|
22
|
+
opts = _hs_handle_http_request_options_follow(opts, hist)
|
23
|
+
|
24
|
+
req = build_request(verb, uri, opts)
|
25
|
+
res = perform(req, opts)
|
26
|
+
return res unless opts.follow
|
27
|
+
|
28
|
+
HTTP::Redirector.new(opts.follow).perform(req, res) do |request|
|
29
|
+
request = HTTP::Session::Request.new(request)
|
30
|
+
perform(request, opts)
|
31
|
+
end.tap do |res|
|
32
|
+
res.history = hist
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# Make an HTTP request.
|
39
|
+
#
|
40
|
+
# @param [Request] req
|
41
|
+
# @param [HTTP::Options] opts
|
42
|
+
# @return [Response]
|
43
|
+
def perform(req, opts)
|
44
|
+
req = wrap_request(req, opts)
|
45
|
+
wrap_response(_hs_perform(req, opts), opts)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Add session cookie to the request's :cookies.
|
49
|
+
def _hs_handle_http_request_options_cookies(opts, cookies)
|
50
|
+
return opts if cookies.nil?
|
51
|
+
opts.with_cookies(cookies)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Wrap the :on_redirect method in the request's :follow.
|
55
|
+
def _hs_handle_http_request_options_follow(opts, hist)
|
56
|
+
return opts unless opts.follow
|
57
|
+
|
58
|
+
follow = (opts.follow == true) ? {} : opts.follow
|
59
|
+
opts.with_follow(follow.merge(
|
60
|
+
on_redirect: _hs_handle_http_request_options_follow_hijack(follow[:on_redirect], hist)
|
61
|
+
))
|
62
|
+
end
|
63
|
+
|
64
|
+
# Wrap the :on_redirect method.
|
65
|
+
def _hs_handle_http_request_options_follow_hijack(fn, hist)
|
66
|
+
lambda do |res, req|
|
67
|
+
hist << res
|
68
|
+
fn.call(res, req) if fn.respond_to?(:call)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Make an HTTP request using cache.
|
73
|
+
def _hs_perform(req, opts)
|
74
|
+
if @session.default_options.cache.enabled?
|
75
|
+
req.cacheable? ? _hs_cache_lookup(req, opts) : _hs_cache_pass(req, opts)
|
76
|
+
else
|
77
|
+
_hs_forward(req, opts)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Try to serve the response from cache.
|
82
|
+
#
|
83
|
+
# * When a matching cache entry is found and is fresh, use it as the response
|
84
|
+
# without forwarding any request to the backend.
|
85
|
+
# * When a matching cache entry is found but is stale, attempt to validate the
|
86
|
+
# entry with the backend using conditional GET.
|
87
|
+
# * When no matching cache entry is found, trigger miss processing.
|
88
|
+
def _hs_cache_lookup(req, opts)
|
89
|
+
entry = @session.cache.read(req)
|
90
|
+
if entry.nil?
|
91
|
+
_hs_cache_fetch(req, opts)
|
92
|
+
elsif entry.response.fresh?(shared: @session.cache.shared?) &&
|
93
|
+
!entry.response.no_cache? &&
|
94
|
+
!req.no_cache?
|
95
|
+
_hs_cache_reuse(req, opts, entry)
|
96
|
+
else
|
97
|
+
_hs_cache_validate(req, opts, entry)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# The cache entry is missing. Forward the request to the backend and determine
|
102
|
+
# whether the response should be stored.
|
103
|
+
def _hs_cache_fetch(req, opts)
|
104
|
+
res = _hs_forward(req, opts)
|
105
|
+
_hs_cache_entry_store(req, res)
|
106
|
+
|
107
|
+
res.headers[HTTP::Session::Cache::Status::HEADER_NAME] = HTTP::Session::Cache::Status::MISS
|
108
|
+
res
|
109
|
+
end
|
110
|
+
|
111
|
+
# The cache entry is fresh, reuse it.
|
112
|
+
def _hs_cache_reuse(req, opts, entry)
|
113
|
+
res = entry.to_response(req)
|
114
|
+
res.headers[HTTP::Headers::AGE] = [(res.now - res.date).to_i, 0].max.to_s
|
115
|
+
res.headers[HTTP::Session::Cache::Status::HEADER_NAME] = HTTP::Session::Cache::Status::HIT
|
116
|
+
res
|
117
|
+
end
|
118
|
+
|
119
|
+
# The cache entry is stale, revalidate it. The original request is used
|
120
|
+
# as a template for a conditional GET request with the backend.
|
121
|
+
def _hs_cache_validate(req, opts, entry)
|
122
|
+
req.headers[HTTP::Headers::IF_MODIFIED_SINCE] = entry.response.last_modified if entry.response.last_modified
|
123
|
+
req.headers[HTTP::Headers::IF_NONE_MATCH] = entry.response.etag if entry.response.etag
|
124
|
+
|
125
|
+
res = _hs_forward(req, opts)
|
126
|
+
if res.status.not_modified?
|
127
|
+
res = entry.to_response(req)
|
128
|
+
res.headers[HTTP::Session::Cache::Status::HEADER_NAME] = HTTP::Session::Cache::Status::REVALIDATED
|
129
|
+
return res
|
130
|
+
end
|
131
|
+
|
132
|
+
_hs_cache_entry_store(req, res)
|
133
|
+
res.headers[HTTP::Session::Cache::Status::HEADER_NAME] = HTTP::Session::Cache::Status::EXPIRED
|
134
|
+
res
|
135
|
+
end
|
136
|
+
|
137
|
+
# The request is not cacheable. So the request is sent to the backend, and the
|
138
|
+
# backend's response is sent to the client, but is not entered into the cache.
|
139
|
+
def _hs_cache_pass(req, opts)
|
140
|
+
res = _hs_forward(req, opts)
|
141
|
+
res.headers[HTTP::Session::Cache::Status::HEADER_NAME] = HTTP::Session::Cache::Status::UNCACHEABLE
|
142
|
+
res
|
143
|
+
end
|
144
|
+
|
145
|
+
# Store the response to cache.
|
146
|
+
def _hs_cache_entry_store(req, res)
|
147
|
+
if res.cacheable?(shared: @session.cache.shared?, req: req)
|
148
|
+
@session.cache.write(req, res)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# Delegate the request to the backend and create the response.
|
153
|
+
def _hs_forward(req, opts)
|
154
|
+
httprb_perform(req, opts)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|