ruby-http-session 1.0.1 → 2.1.0

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.
@@ -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
@@ -0,0 +1,7 @@
1
+ class HTTP::Session
2
+ module Exceptions
3
+ Error = Class.new(HTTP::Error)
4
+ PoolTimeoutError = Class.new(Error)
5
+ RedirectError = Class.new(Error)
6
+ end
7
+ end
@@ -1,5 +1,3 @@
1
- require "set"
2
-
3
1
  class HTTP::Session
4
2
  module Features
5
3
  class AutoInflate < HTTP::Feature
@@ -53,8 +51,6 @@ class HTTP::Session
53
51
  ensure
54
52
  zstream.close
55
53
  end
56
-
57
- HTTP::Options.register_feature(:hsf_auto_inflate, self)
58
54
  end
59
55
  end
60
56
  end
@@ -0,0 +1,5 @@
1
+ class HTTP::Session
2
+ module Features
3
+ HTTP::Options.register_feature(:hsf_auto_inflate, HTTP::Session::Features::AutoInflate)
4
+ end
5
+ 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] 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)
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
- !!options[:shared]
28
- elsif options.key?(:private)
29
- !options[:private]
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 @enabled
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
- # @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
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
- @enabled = options.fetch(:enabled, true)
23
+ # @!visibility private
24
+ def freeze
25
+ super
14
26
  end
15
27
 
16
- # Indicates whether or not the session cookie feature is enabled.
17
- def enabled?
18
- @enabled
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
@@ -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
@@ -1,5 +1,3 @@
1
- require "forwardable"
2
-
3
1
  class HTTP::Session
4
2
  class Response < SimpleDelegator
5
3
  class StringBody
@@ -1,5 +1,5 @@
1
1
  module HTTP
2
2
  class Session
3
- VERSION = "1.0.1"
3
+ VERSION = "2.1.0"
4
4
  end
5
5
  end