ruby-http-session 1.0.1 → 2.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -1
- data/README.md +164 -71
- data/http-session.gemspec +2 -2
- data/lib/http/session/cache.rb +22 -17
- data/lib/http/session/client/perform.rb +7 -7
- data/lib/http/session/client.rb +75 -49
- data/lib/http/session/configurable.rb +1 -2
- data/lib/http/session/connection_pool.rb +88 -0
- data/lib/http/session/context/follow_context.rb +24 -0
- data/lib/http/session/context.rb +13 -0
- data/lib/http/session/cookies.rb +55 -0
- data/lib/http/session/exceptions.rb +7 -0
- data/lib/http/session/features/auto_inflate.rb +0 -4
- data/lib/http/session/features.rb +5 -0
- data/lib/http/session/options/cache_option.rb +20 -26
- data/lib/http/session/options/cookies_option.rb +31 -11
- data/lib/http/session/options/optionable.rb +21 -0
- data/lib/http/session/options/persistent_option.rb +35 -0
- data/lib/http/session/options.rb +15 -0
- data/lib/http/session/pool_manager.rb +67 -0
- data/lib/http/session/redirector.rb +89 -0
- data/lib/http/session/response/string_body.rb +0 -2
- data/lib/http/session/version.rb +1 -1
- data/lib/http/session.rb +45 -48
- data/lib/http-session.rb +1 -0
- metadata +15 -5
@@ -0,0 +1,88 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class ConnectionPool
|
3
|
+
include MonitorMixin
|
4
|
+
|
5
|
+
DEFAULT_POOL_TIMEOUT = 5
|
6
|
+
DEFAULT_POOL_MAXSIZE = 5
|
7
|
+
DEFAULT_CONN_KEEP_ALIVE_TIMEOUT = 5
|
8
|
+
|
9
|
+
# The number of connections can be reused.
|
10
|
+
attr_reader :maxsize
|
11
|
+
|
12
|
+
# Returns a new instance of ConnectionPool.
|
13
|
+
#
|
14
|
+
# @param [Hash] opts
|
15
|
+
def initialize(opts, &blk)
|
16
|
+
super()
|
17
|
+
|
18
|
+
@host = opts.fetch(:host)
|
19
|
+
@timeout = opts.fetch(:timeout, DEFAULT_CONN_KEEP_ALIVE_TIMEOUT)
|
20
|
+
@maxsize = opts.fetch(:maxsize, DEFAULT_POOL_MAXSIZE)
|
21
|
+
|
22
|
+
@create_blk = blk
|
23
|
+
@created = 0
|
24
|
+
@que = []
|
25
|
+
@resource = new_cond
|
26
|
+
end
|
27
|
+
|
28
|
+
# Obtain a connection.
|
29
|
+
def with(timeout: DEFAULT_POOL_TIMEOUT, &blk)
|
30
|
+
conn = checkout(timeout: timeout)
|
31
|
+
blk.call(conn)
|
32
|
+
ensure
|
33
|
+
checkin(conn) if conn
|
34
|
+
end
|
35
|
+
|
36
|
+
# The number of connections available.
|
37
|
+
def size
|
38
|
+
@maxsize - @created + @que.size
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def checkout(timeout:)
|
44
|
+
deadline = current_time + timeout
|
45
|
+
|
46
|
+
synchronize do
|
47
|
+
loop do
|
48
|
+
# Reuse a free connection.
|
49
|
+
if @que.size > 0
|
50
|
+
break @que.pop
|
51
|
+
end
|
52
|
+
|
53
|
+
# Create a new connection if the pool does not reach @maxsize.
|
54
|
+
if @created < @maxsize
|
55
|
+
@created += 1
|
56
|
+
break make_conn
|
57
|
+
end
|
58
|
+
|
59
|
+
# .
|
60
|
+
to_wait = deadline - current_time
|
61
|
+
raise HTTP::Session::Exceptions::PoolTimeoutError, "Waited #{timeout} sec, #{size}/#{maxsize} available" if to_wait <= 0
|
62
|
+
|
63
|
+
# Block until a connection is put back to @que.
|
64
|
+
@resource.wait(to_wait)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def checkin(conn)
|
70
|
+
synchronize do
|
71
|
+
# Put back to @que.
|
72
|
+
@que.push(conn)
|
73
|
+
|
74
|
+
# Wakes up all threads waiting for this resource.
|
75
|
+
@resource.broadcast
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def make_conn
|
80
|
+
opts = {persistent: @host, keep_alive_timeout: @timeout}
|
81
|
+
@create_blk.call(opts)
|
82
|
+
end
|
83
|
+
|
84
|
+
def current_time
|
85
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Context
|
3
|
+
class FollowContext
|
4
|
+
def initialize(opts)
|
5
|
+
@request = opts[:request]
|
6
|
+
@verb = opts[:verb]
|
7
|
+
@uri = opts[:uri]
|
8
|
+
end
|
9
|
+
|
10
|
+
def same_origin?
|
11
|
+
@request.uri.origin == @uri.origin
|
12
|
+
end
|
13
|
+
|
14
|
+
def cross_origin?
|
15
|
+
!same_origin?
|
16
|
+
end
|
17
|
+
|
18
|
+
def should_drop_body?
|
19
|
+
@verb == :get &&
|
20
|
+
HTTP::Session::Redirector::UNSAFE_VERBS.include?(@request.verb)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Context
|
3
|
+
# @!attribute [r] follow
|
4
|
+
# @return [nil, FollowContext]
|
5
|
+
attr_reader :follow
|
6
|
+
|
7
|
+
# @param [Hash] options
|
8
|
+
# @option options [nil, FollowContext] :follow
|
9
|
+
def initialize(options)
|
10
|
+
@follow = options[:follow]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Cookies
|
3
|
+
extend Forwardable
|
4
|
+
include MonitorMixin
|
5
|
+
|
6
|
+
# @!method enabled?
|
7
|
+
# True when it is enabled.
|
8
|
+
# @return [Boolean]
|
9
|
+
def_delegator :@options, :enabled?
|
10
|
+
|
11
|
+
# @param [Options::CookiesOption] options
|
12
|
+
def initialize(options)
|
13
|
+
super()
|
14
|
+
@options = options
|
15
|
+
end
|
16
|
+
|
17
|
+
# Read cookies.
|
18
|
+
#
|
19
|
+
# @return [nil, Hash]
|
20
|
+
def read(uri)
|
21
|
+
synchronize do
|
22
|
+
read_cookies(uri)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Write cookies.
|
27
|
+
#
|
28
|
+
# @param [Response] res
|
29
|
+
# @return [void]
|
30
|
+
def write(res)
|
31
|
+
synchronize do
|
32
|
+
write_cookies(res)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def read_cookies(uri)
|
39
|
+
return if jar.empty?
|
40
|
+
jar.cookies(uri).each_with_object({}) { |c, h| h[c.name] = c.value }
|
41
|
+
end
|
42
|
+
|
43
|
+
def write_cookies(res)
|
44
|
+
req = res.request
|
45
|
+
res.headers.get(HTTP::Headers::SET_COOKIE).each do |header|
|
46
|
+
jar.parse(header, req.uri)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Only available when #enbled? has the value true.
|
51
|
+
def jar
|
52
|
+
@options.jar
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -1,40 +1,34 @@
|
|
1
1
|
class HTTP::Session
|
2
2
|
class Options
|
3
3
|
class CacheOption
|
4
|
+
include Optionable
|
5
|
+
|
4
6
|
# @!attribute [r] store
|
5
7
|
# @return [ActiveSupport::Cache::Store]
|
6
8
|
attr_reader :store
|
7
9
|
|
8
|
-
# @param [Hash]
|
9
|
-
# @option
|
10
|
-
# @option
|
11
|
-
# @option
|
12
|
-
def initialize(
|
13
|
-
|
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)
|
10
|
+
# @param [Hash] opts
|
11
|
+
# @option opts [Boolean] :private set true if it is a private cache
|
12
|
+
# @option opts [Boolean] :shared set true if it is a shared cache
|
13
|
+
# @option opts [ActiveSupport::Cache::Store] :store
|
14
|
+
def initialize(opts)
|
15
|
+
initialize_options(opts)
|
22
16
|
|
23
17
|
# Shared Cache / Private Cache
|
24
18
|
@shared =
|
25
|
-
if options.key?(:shared)
|
26
|
-
raise ArgumentError, ":shared and :private cannot be used at the same time" if options.key?(:private)
|
27
|
-
|
28
|
-
elsif options.key?(:private)
|
29
|
-
|
19
|
+
if @options.key?(:shared)
|
20
|
+
raise ArgumentError, ":shared and :private cannot be used at the same time" if @options.key?(:private)
|
21
|
+
!!@options[:shared]
|
22
|
+
elsif @options.key?(:private)
|
23
|
+
!@options[:private]
|
30
24
|
else
|
31
25
|
true
|
32
26
|
end
|
33
27
|
|
34
28
|
# Cache Store
|
35
29
|
@store =
|
36
|
-
if
|
37
|
-
store = options[:store]
|
30
|
+
if enabled?
|
31
|
+
store = @options[:store]
|
38
32
|
if store.respond_to?(:read) && store.respond_to?(:write)
|
39
33
|
store
|
40
34
|
else
|
@@ -43,11 +37,6 @@ class HTTP::Session
|
|
43
37
|
end
|
44
38
|
end
|
45
39
|
|
46
|
-
# Indicates whether or not the session cache feature is enabled.
|
47
|
-
def enabled?
|
48
|
-
@enabled
|
49
|
-
end
|
50
|
-
|
51
40
|
# True when it is a shared cache.
|
52
41
|
#
|
53
42
|
# Shared Cache that exists between the origin server and clients (e.g. Proxy, CDN).
|
@@ -64,6 +53,11 @@ class HTTP::Session
|
|
64
53
|
!shared_cache?
|
65
54
|
end
|
66
55
|
|
56
|
+
# @!visibility private
|
57
|
+
def freeze
|
58
|
+
super
|
59
|
+
end
|
60
|
+
|
67
61
|
private
|
68
62
|
|
69
63
|
def lookup_store(store)
|
@@ -1,21 +1,41 @@
|
|
1
1
|
class HTTP::Session
|
2
2
|
class Options
|
3
3
|
class CookiesOption
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
4
|
+
include Optionable
|
5
|
+
|
6
|
+
# @!attribute [r] jar
|
7
|
+
# @return [HTTP::CookieJar]
|
8
|
+
attr_reader :jar
|
9
|
+
|
10
|
+
# @param [Hash] opts
|
11
|
+
# @option opts [HTTP::CookieJar] :jar
|
12
|
+
def initialize(opts)
|
13
|
+
initialize_options(opts)
|
14
|
+
|
15
|
+
# CookieJar
|
16
|
+
@jar =
|
17
|
+
if enabled?
|
18
|
+
jar = @options[:jar]
|
19
|
+
lookup_jar(jar)
|
11
20
|
end
|
21
|
+
end
|
12
22
|
|
13
|
-
|
23
|
+
# @!visibility private
|
24
|
+
def freeze
|
25
|
+
super
|
14
26
|
end
|
15
27
|
|
16
|
-
|
17
|
-
|
18
|
-
|
28
|
+
private
|
29
|
+
|
30
|
+
def lookup_jar(jar)
|
31
|
+
case jar
|
32
|
+
when Hash
|
33
|
+
HTTP::CookieJar.new(**jar)
|
34
|
+
when nil
|
35
|
+
HTTP::CookieJar.new
|
36
|
+
else
|
37
|
+
jar
|
38
|
+
end
|
19
39
|
end
|
20
40
|
end
|
21
41
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Options
|
3
|
+
module Optionable
|
4
|
+
def initialize_options(opts)
|
5
|
+
@options =
|
6
|
+
case opts
|
7
|
+
when nil, false then {enabled: false}
|
8
|
+
when true then {enabled: true}
|
9
|
+
else opts
|
10
|
+
end
|
11
|
+
|
12
|
+
@enabled =
|
13
|
+
@options.fetch(:enabled, true)
|
14
|
+
end
|
15
|
+
|
16
|
+
def enabled?
|
17
|
+
@enabled
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class Options
|
3
|
+
class PersistentOption
|
4
|
+
include Optionable
|
5
|
+
|
6
|
+
# @!attribute [r] pools
|
7
|
+
# @return [Hash]
|
8
|
+
attr_reader :pools
|
9
|
+
|
10
|
+
# @param [Hash] opts
|
11
|
+
# @option opts [Hash] :pools parameters used to create ConnectionPool
|
12
|
+
def initialize(opts)
|
13
|
+
initialize_options(opts)
|
14
|
+
|
15
|
+
@pools =
|
16
|
+
normalize_pools(@options.fetch(:pools, {"*" => true}))
|
17
|
+
end
|
18
|
+
|
19
|
+
# @!visibility private
|
20
|
+
def freeze
|
21
|
+
super.tap do
|
22
|
+
pools.freeze
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def normalize_pools(pools)
|
29
|
+
pools.transform_keys do |k|
|
30
|
+
(k == "*") ? k : HTTP::URI.parse(k).origin
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/http/session/options.rb
CHANGED
@@ -8,6 +8,10 @@ class HTTP::Session
|
|
8
8
|
# @return [CacheOption]
|
9
9
|
attr_reader :cache
|
10
10
|
|
11
|
+
# @!attribute [r] persistent
|
12
|
+
# @return [PersistentOption]
|
13
|
+
attr_reader :persistent
|
14
|
+
|
11
15
|
# @!attribute [r] http
|
12
16
|
# @return [HTTP::Options]
|
13
17
|
attr_reader :http
|
@@ -16,6 +20,7 @@ class HTTP::Session
|
|
16
20
|
def initialize(options)
|
17
21
|
@cookies = HTTP::Session::Options::CookiesOption.new(options.fetch(:cookies, false))
|
18
22
|
@cache = HTTP::Session::Options::CacheOption.new(options.fetch(:cache, false))
|
23
|
+
@persistent = HTTP::Session::Options::PersistentOption.new(options.fetch(:persistent, false))
|
19
24
|
@http = HTTP::Options.new(
|
20
25
|
options.fetch(:http, {}).slice(
|
21
26
|
:cookies,
|
@@ -76,5 +81,15 @@ class HTTP::Session
|
|
76
81
|
def with_proxy(proxy)
|
77
82
|
tap { @http = @http.with_proxy(proxy) }
|
78
83
|
end
|
84
|
+
|
85
|
+
# @!visibility private
|
86
|
+
def freeze
|
87
|
+
super.tap do
|
88
|
+
cookies.freeze
|
89
|
+
cache.freeze
|
90
|
+
persistent.freeze
|
91
|
+
http.freeze
|
92
|
+
end
|
93
|
+
end
|
79
94
|
end
|
80
95
|
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
class PoolManager
|
3
|
+
include MonitorMixin
|
4
|
+
|
5
|
+
# Returns a new instance of PoolManager.
|
6
|
+
#
|
7
|
+
# @param [Options::PersistentOption] options
|
8
|
+
# @param [HTTP::Session] session
|
9
|
+
def initialize(options, session)
|
10
|
+
super()
|
11
|
+
@options = options
|
12
|
+
@session = session
|
13
|
+
@pools = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
# Obtain a connection.
|
17
|
+
def with(uri, &blk)
|
18
|
+
return with_new_conn(&blk) unless @options.enabled?
|
19
|
+
|
20
|
+
origin = HTTP::URI.parse(uri).origin
|
21
|
+
return with_new_conn(&blk) unless connection_pool_usable?(origin)
|
22
|
+
|
23
|
+
with_reusable_conn(origin, &blk)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def with_new_conn(&blk)
|
29
|
+
blk.call(make_conn)
|
30
|
+
end
|
31
|
+
|
32
|
+
def with_reusable_conn(origin, &blk)
|
33
|
+
pool = connection_pool_from_origin(origin)
|
34
|
+
pool.with { |conn| blk.call(conn) }
|
35
|
+
end
|
36
|
+
|
37
|
+
def make_conn(opts = {})
|
38
|
+
opts = @session.default_options.http.merge(opts)
|
39
|
+
HTTP::Session::Client.new(opts, @session)
|
40
|
+
end
|
41
|
+
|
42
|
+
def connection_pool_usable?(origin)
|
43
|
+
opts = connection_pool_options_from_origin(origin)
|
44
|
+
return false if opts.nil?
|
45
|
+
return false if opts == false
|
46
|
+
true
|
47
|
+
end
|
48
|
+
|
49
|
+
def connection_pool_options_from_origin(origin)
|
50
|
+
return @options.pools[origin] if @options.pools.key?(origin)
|
51
|
+
@options.pools["*"]
|
52
|
+
end
|
53
|
+
|
54
|
+
def connection_pool_from_origin(origin)
|
55
|
+
synchronize do
|
56
|
+
return @pools[origin] if @pools.key?(origin)
|
57
|
+
|
58
|
+
opts = connection_pool_options_from_origin(origin).dup
|
59
|
+
opts = {} if opts == true
|
60
|
+
opts[:host] = origin
|
61
|
+
@pools[origin] = ConnectionPool.new(opts, &->(conn_opts) {
|
62
|
+
make_conn(conn_opts)
|
63
|
+
})
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
class HTTP::Session
|
2
|
+
# Mostly borrowed from [http/lib/http/redirector.rb](https://github.com/httprb/http/blob/main/lib/http/redirector.rb)
|
3
|
+
class Redirector
|
4
|
+
# HTTP status codes which indicate redirects
|
5
|
+
REDIRECT_CODES = HTTP::Redirector::REDIRECT_CODES
|
6
|
+
|
7
|
+
# Codes which which should raise StateError in strict mode if original
|
8
|
+
# request was any of {UNSAFE_VERBS}
|
9
|
+
STRICT_SENSITIVE_CODES = HTTP::Redirector::STRICT_SENSITIVE_CODES
|
10
|
+
|
11
|
+
# Insecure http verbs, which should trigger StateError in strict mode
|
12
|
+
# upon {STRICT_SENSITIVE_CODES}
|
13
|
+
UNSAFE_VERBS = HTTP::Redirector::UNSAFE_VERBS
|
14
|
+
|
15
|
+
# Verbs which will remain unchanged upon See Other response.
|
16
|
+
SEE_OTHER_ALLOWED_VERBS = HTTP::Redirector::SEE_OTHER_ALLOWED_VERBS
|
17
|
+
|
18
|
+
# @param [Hash] opts
|
19
|
+
# @option opts [Boolean] :strict (true) redirector hops policy
|
20
|
+
# @option opts [#to_i] :max_hops (5) maximum allowed amount of hops
|
21
|
+
def initialize(opts = {})
|
22
|
+
@strict = opts.fetch(:strict, true)
|
23
|
+
@max_hops = opts.fetch(:max_hops, 5).to_i
|
24
|
+
@on_redirect = opts.fetch(:on_redirect, nil)
|
25
|
+
end
|
26
|
+
|
27
|
+
def perform(response, &blk)
|
28
|
+
request = response.request
|
29
|
+
visited = []
|
30
|
+
history = []
|
31
|
+
|
32
|
+
while REDIRECT_CODES.include?(response.status.code)
|
33
|
+
history << response
|
34
|
+
visited << "#{request.verb} #{request.uri}"
|
35
|
+
raise HTTP::Session::Exceptions::RedirectError, "too many hops" if too_many_hops?(visited)
|
36
|
+
raise HTTP::Session::Exceptions::RedirectError, "endless loop" if endless_loop?(visited)
|
37
|
+
|
38
|
+
location = response.headers.get(HTTP::Headers::LOCATION).inject(:+)
|
39
|
+
raise HTTP::Session::Exceptions::RedirectError, "no Location header in redirect" unless location
|
40
|
+
|
41
|
+
verb = make_redirect_to_verb(response, request)
|
42
|
+
uri = make_redirect_to_uri(response, request, location)
|
43
|
+
ctx = make_redirect_to_ctx(response, request, verb, uri)
|
44
|
+
|
45
|
+
@on_redirect.call(response, request) if @on_redirect.respond_to?(:call)
|
46
|
+
response = blk.call(verb, uri, ctx)
|
47
|
+
request = response.request
|
48
|
+
end
|
49
|
+
|
50
|
+
response.history = history
|
51
|
+
response
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def too_many_hops?(visited)
|
57
|
+
@max_hops >= 1 && @max_hops < visited.count
|
58
|
+
end
|
59
|
+
|
60
|
+
def endless_loop?(visited)
|
61
|
+
visited.count(visited.last) >= 2
|
62
|
+
end
|
63
|
+
|
64
|
+
def make_redirect_to_verb(response, request)
|
65
|
+
verb = request.verb
|
66
|
+
code = response.status.code
|
67
|
+
|
68
|
+
if UNSAFE_VERBS.include?(verb) && STRICT_SENSITIVE_CODES.include?(code)
|
69
|
+
raise HTTP::Session::Exceptions::RedirectError, "can't follow #{response.status} redirect" if @strict
|
70
|
+
verb = :get
|
71
|
+
end
|
72
|
+
if !SEE_OTHER_ALLOWED_VERBS.include?(verb) && code == 303
|
73
|
+
verb = :get
|
74
|
+
end
|
75
|
+
|
76
|
+
verb
|
77
|
+
end
|
78
|
+
|
79
|
+
def make_redirect_to_uri(response, request, location)
|
80
|
+
request.uri.join(location)
|
81
|
+
end
|
82
|
+
|
83
|
+
def make_redirect_to_ctx(response, request, verb, uri)
|
84
|
+
HTTP::Session::Context::FollowContext.new(
|
85
|
+
request: request, verb: verb, uri: uri
|
86
|
+
)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
data/lib/http/session/version.rb
CHANGED