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