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,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
|