backport 0.2.0 → 0.3.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/.gitignore +3 -0
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +6 -0
- data/README.md +25 -2
- data/lib/backport.rb +72 -66
- data/lib/backport/adapter.rb +34 -16
- data/lib/backport/client.rb +20 -1
- data/lib/backport/machine.rb +12 -4
- data/lib/backport/server.rb +2 -0
- data/lib/backport/server/base.rb +20 -8
- data/lib/backport/server/connectable.rb +5 -0
- data/lib/backport/server/interval.rb +11 -2
- data/lib/backport/server/stdio.rb +2 -0
- data/lib/backport/server/tcpip.rb +44 -25
- data/lib/backport/version.rb +3 -3
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9693d9fb93dc1efb03c85d3abe258cd54e2fa4bca03e8bc2f64a45a56783269a
|
4
|
+
data.tar.gz: a7abdaa602ccbfda748cdace2af071af73651ecff42663ac237def501260f457
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bbc92f9838a571f7cb9ea71334961cf34013a41e1010147ec4f54d4c5efde68c37b4f7cb9f0d9d52ad8ebe23ac9fa83e24568d690044ed200d838a1cb2c50358
|
7
|
+
data.tar.gz: a5cdf05eb09d40bc352fd07ed2d23b85107875551c9718e0145e7abdf92f670ff784e99b50b8daf5879c7a0cbe92dca1d8ca4b3a42ed73a482610085fd56e986
|
data/.gitignore
CHANGED
data/.rubocop.yml
ADDED
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,9 @@
|
|
1
|
+
## 0.3.0 - January 10, 2019
|
2
|
+
- Basic logging
|
3
|
+
- Differentiate between "expected" and "unexpected" exceptions in Tcpip
|
4
|
+
- Prefer #start to #run for non-blocking client/server methods
|
5
|
+
- Interval servers can stop themselves
|
6
|
+
|
1
7
|
## 0.2.0 - December 21, 2018
|
2
8
|
- Minor bug fixes in STDIO server
|
3
9
|
- More efficient client reads
|
data/README.md
CHANGED
@@ -20,6 +20,8 @@ gem 'backport'
|
|
20
20
|
|
21
21
|
## Usage
|
22
22
|
|
23
|
+
### Examples
|
24
|
+
|
23
25
|
A simple echo server:
|
24
26
|
|
25
27
|
```ruby
|
@@ -27,11 +29,11 @@ require 'backport'
|
|
27
29
|
|
28
30
|
module MyAdapter
|
29
31
|
def opening
|
30
|
-
|
32
|
+
puts "Opening a connection"
|
31
33
|
end
|
32
34
|
|
33
35
|
def closing
|
34
|
-
|
36
|
+
puts "Closing a connection"
|
35
37
|
end
|
36
38
|
|
37
39
|
def sending data
|
@@ -55,3 +57,24 @@ Backport.run do
|
|
55
57
|
end
|
56
58
|
end
|
57
59
|
```
|
60
|
+
|
61
|
+
### Using Adapters
|
62
|
+
|
63
|
+
Backport servers that handle client connections, such as TCP servers, use an
|
64
|
+
adapter to provide an application interface to the client. Developers can
|
65
|
+
provide their own adapter implementations in two ways: a Ruby module that will
|
66
|
+
be used to extend a Backport::Adapter object, or a class that extends
|
67
|
+
Backport::Adapter. In either case, the adapter should provide the following
|
68
|
+
methods:
|
69
|
+
|
70
|
+
* `opening`: A callback triggered when the client connection is accepted
|
71
|
+
* `closing`: A callback triggered when the client connection is closed
|
72
|
+
* `sending(data)`: A callback triggered when the server receives data from the client
|
73
|
+
|
74
|
+
Backport::Adapter also provides the following methods:
|
75
|
+
|
76
|
+
* `write(data)`: Send raw data to the client
|
77
|
+
* `write_line(data)`: Send a line of data to the client
|
78
|
+
* `close`: Disconnect the client from the server
|
79
|
+
* `closed?`: True if the connection is closed
|
80
|
+
* `remote`: A hash of data about the client, e.g., the remote IP address
|
data/lib/backport.rb
CHANGED
@@ -1,66 +1,72 @@
|
|
1
|
-
require "backport/version"
|
2
|
-
require '
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
autoload :
|
8
|
-
autoload :
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
#
|
14
|
-
#
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
#
|
22
|
-
#
|
23
|
-
# @param
|
24
|
-
# @
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
#
|
32
|
-
#
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
#
|
40
|
-
#
|
41
|
-
#
|
42
|
-
#
|
43
|
-
#
|
44
|
-
#
|
45
|
-
#
|
46
|
-
#
|
47
|
-
#
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
#
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
1
|
+
require "backport/version"
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
# An event-driven IO library.
|
5
|
+
#
|
6
|
+
module Backport
|
7
|
+
autoload :Adapter, 'backport/adapter'
|
8
|
+
autoload :Machine, 'backport/machine'
|
9
|
+
autoload :Server, 'backport/server'
|
10
|
+
autoload :Client, 'backport/client'
|
11
|
+
|
12
|
+
class << self
|
13
|
+
# Prepare a STDIO server to run in Backport.
|
14
|
+
#
|
15
|
+
# @param adapter [Adapter]
|
16
|
+
# @return [void]
|
17
|
+
def prepare_stdio_server adapter: Adapter
|
18
|
+
machine.prepare Backport::Server::Stdio.new(adapter: adapter)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Prepare a TCP server to run in Backport.
|
22
|
+
#
|
23
|
+
# @param host [String]
|
24
|
+
# @param port [Integer]
|
25
|
+
# @param adapter [Adapter]
|
26
|
+
# @return [void]
|
27
|
+
def prepare_tcp_server host: 'localhost', port: 1117, adapter: Adapter
|
28
|
+
machine.prepare Backport::Server::Tcpip.new(host: host, port: port, adapter: adapter)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Prepare an interval server to run in Backport.
|
32
|
+
#
|
33
|
+
# @param period [Float] Seconds between intervals
|
34
|
+
# @return [void]
|
35
|
+
def prepare_interval period, &block
|
36
|
+
machine.prepare Backport::Server::Interval.new(period, &block)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Run the Backport machine. The provided block will be executed before the
|
40
|
+
# machine starts. Program execution is blocked until the machine stops.
|
41
|
+
#
|
42
|
+
# @example Print "tick" once per second
|
43
|
+
# Backport.run do
|
44
|
+
# Backport.prepare_interval 1 do
|
45
|
+
# puts "tick"
|
46
|
+
# end
|
47
|
+
# end
|
48
|
+
#
|
49
|
+
# @return [void]
|
50
|
+
def run &block
|
51
|
+
machine.run &block
|
52
|
+
end
|
53
|
+
|
54
|
+
# Stop the Backport machine.
|
55
|
+
#
|
56
|
+
# @return [void]
|
57
|
+
def stop
|
58
|
+
machine.stop
|
59
|
+
end
|
60
|
+
|
61
|
+
def logger
|
62
|
+
@logger ||= Logger.new(STDERR, level: Logger::WARN, progname: 'Backport')
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
# @return [Machine]
|
68
|
+
def machine
|
69
|
+
@machine ||= Machine.new
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
data/lib/backport/adapter.rb
CHANGED
@@ -1,17 +1,29 @@
|
|
1
1
|
module Backport
|
2
|
+
# The application interface between Backport servers and clients.
|
3
|
+
#
|
2
4
|
class Adapter
|
5
|
+
# @param output [IO]
|
6
|
+
# @param remote [Hash{Symbol => String, Integer}]
|
7
|
+
def initialize output, remote = {}
|
8
|
+
# Store internal data in a singleton method to avoid instance variable
|
9
|
+
# collisions in custom adapters
|
10
|
+
data = {
|
11
|
+
out: output,
|
12
|
+
remote: remote,
|
13
|
+
closed: false
|
14
|
+
}
|
15
|
+
define_singleton_method :_data do
|
16
|
+
data
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
3
20
|
# A hash of information about the client connection. The data can vary
|
4
21
|
# based on the transport, e.g., :hostname and :address for TCP connections
|
5
22
|
# or :filename for file streams.
|
6
23
|
#
|
7
24
|
# @return [Hash{Symbol => String, Integer}]
|
8
|
-
|
9
|
-
|
10
|
-
# @param output [IO]
|
11
|
-
# @param remote [Hash{Symbol => String, Integer}]
|
12
|
-
def initialize output, remote = {}
|
13
|
-
@out = output
|
14
|
-
@remote = remote
|
25
|
+
def remote
|
26
|
+
_data[:remote]
|
15
27
|
end
|
16
28
|
|
17
29
|
# A callback triggered when a client connection is opening. Subclasses
|
@@ -28,9 +40,9 @@ module Backport
|
|
28
40
|
# @return [void]
|
29
41
|
def closing; end
|
30
42
|
|
31
|
-
# A callback triggered when the client
|
32
|
-
#
|
33
|
-
#
|
43
|
+
# A callback triggered when the client sends data to the server. Subclasses
|
44
|
+
# and/or modules should override this method to provide their own
|
45
|
+
# functionality.
|
34
46
|
#
|
35
47
|
# @param data [String]
|
36
48
|
# @return [void]
|
@@ -41,8 +53,8 @@ module Backport
|
|
41
53
|
# @param data [String]
|
42
54
|
# @return [void]
|
43
55
|
def write data
|
44
|
-
|
45
|
-
|
56
|
+
_data[:out].write data
|
57
|
+
_data[:out].flush
|
46
58
|
end
|
47
59
|
|
48
60
|
# Send a line of data to the client.
|
@@ -50,17 +62,23 @@ module Backport
|
|
50
62
|
# @param data [String]
|
51
63
|
# @return [void]
|
52
64
|
def write_line data
|
53
|
-
|
54
|
-
|
65
|
+
_data[:out].puts data
|
66
|
+
_data[:out].flush
|
55
67
|
end
|
56
68
|
|
57
69
|
def closed?
|
58
|
-
|
70
|
+
_data[:closed] ||= false
|
59
71
|
end
|
60
72
|
|
73
|
+
# Close the client connection.
|
74
|
+
#
|
75
|
+
# @note The adapter sets #closed? to true and runs the #closing callback.
|
76
|
+
# The server is responsible for implementation details like closing the
|
77
|
+
# client's socket.
|
78
|
+
#
|
61
79
|
def close
|
62
80
|
return if closed?
|
63
|
-
|
81
|
+
_data[:closed] = true
|
64
82
|
closing
|
65
83
|
end
|
66
84
|
end
|
data/lib/backport/client.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
module Backport
|
2
|
+
# A client connected to a connectable Backport server.
|
3
|
+
#
|
2
4
|
class Client
|
3
5
|
# @return [Adapter]
|
4
6
|
attr_reader :adapter
|
@@ -15,23 +17,40 @@ module Backport
|
|
15
17
|
@stopped ||= false
|
16
18
|
end
|
17
19
|
|
20
|
+
# Close the client connection.
|
21
|
+
#
|
22
|
+
# @note The client sets #stopped? to true and runs the adapter's #closing
|
23
|
+
# callback. The server is responsible for implementation details like
|
24
|
+
# closing the client's socket.
|
25
|
+
#
|
18
26
|
def stop
|
19
27
|
return if stopped?
|
20
28
|
@adapter.closing
|
21
29
|
@stopped = true
|
22
30
|
end
|
23
31
|
|
24
|
-
|
32
|
+
# Start running the client. This method will start the thread that reads
|
33
|
+
# client input from IO.
|
34
|
+
#
|
35
|
+
def start
|
25
36
|
return unless stopped?
|
26
37
|
@stopped = false
|
27
38
|
@adapter.opening
|
28
39
|
run_input_thread
|
29
40
|
end
|
41
|
+
# @deprecated Prefer #start to #run for non-blocking client/server methods
|
42
|
+
alias run start
|
30
43
|
|
44
|
+
# Notify the adapter that the client is sending data.
|
45
|
+
#
|
46
|
+
# @param data [String]
|
31
47
|
def sending data
|
32
48
|
@adapter.sending data
|
33
49
|
end
|
34
50
|
|
51
|
+
# Read the client input. Return nil if the input buffer is empty.
|
52
|
+
#
|
53
|
+
# @return [String, nil]
|
35
54
|
def read
|
36
55
|
tmp = nil
|
37
56
|
mutex.synchronize do
|
data/lib/backport/machine.rb
CHANGED
@@ -1,11 +1,13 @@
|
|
1
1
|
module Backport
|
2
|
+
# The Backport server controller.
|
3
|
+
#
|
2
4
|
class Machine
|
3
5
|
def initialize
|
4
6
|
@stopped = true
|
5
7
|
end
|
6
8
|
|
7
9
|
# Run the machine. If a block is provided, it gets executed before the
|
8
|
-
# maching starts its main loop. The main loop
|
10
|
+
# maching starts its main loop. The main loop blocks program execution
|
9
11
|
# until the machine is stopped.
|
10
12
|
#
|
11
13
|
# @return [void]
|
@@ -43,17 +45,23 @@ module Backport
|
|
43
45
|
server.start unless stopped?
|
44
46
|
end
|
45
47
|
|
46
|
-
private
|
47
|
-
|
48
48
|
# @return [Array<Server::Base>]
|
49
49
|
def servers
|
50
50
|
@servers ||= []
|
51
51
|
end
|
52
52
|
|
53
|
+
def tick
|
54
|
+
servers.delete_if(&:stopped?)
|
55
|
+
stop if servers.empty?
|
56
|
+
servers.each(&:tick)
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
53
61
|
def run_server_thread
|
54
62
|
servers.map(&:start)
|
55
63
|
until stopped?
|
56
|
-
|
64
|
+
tick
|
57
65
|
sleep 0.001
|
58
66
|
end
|
59
67
|
end
|
data/lib/backport/server.rb
CHANGED
data/lib/backport/server/base.rb
CHANGED
@@ -1,16 +1,33 @@
|
|
1
1
|
module Backport
|
2
2
|
module Server
|
3
|
+
# An extendable server class that provides basic start/stop functionality
|
4
|
+
# and common callbacks.
|
5
|
+
#
|
3
6
|
class Base
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
+
# Start the server.
|
8
|
+
#
|
9
|
+
def start
|
10
|
+
return if started?
|
11
|
+
starting
|
12
|
+
@started = true
|
7
13
|
end
|
8
14
|
|
15
|
+
# Stop the server.
|
16
|
+
#
|
9
17
|
def stop
|
18
|
+
return if stopped?
|
10
19
|
stopping
|
11
20
|
@started = false
|
12
21
|
end
|
13
22
|
|
23
|
+
def started?
|
24
|
+
@started ||= false
|
25
|
+
end
|
26
|
+
|
27
|
+
def stopped?
|
28
|
+
!started?
|
29
|
+
end
|
30
|
+
|
14
31
|
# A callback triggered when a Machine starts running or the server is
|
15
32
|
# added to a running machine. Subclasses should override this method to
|
16
33
|
# provide their own functionality.
|
@@ -30,11 +47,6 @@ module Backport
|
|
30
47
|
#
|
31
48
|
# @return [void]
|
32
49
|
def tick; end
|
33
|
-
|
34
|
-
def start
|
35
|
-
starting
|
36
|
-
@started = true
|
37
|
-
end
|
38
50
|
end
|
39
51
|
end
|
40
52
|
end
|
@@ -1,5 +1,9 @@
|
|
1
1
|
module Backport
|
2
2
|
module Server
|
3
|
+
# A mixin for Backport servers that communicate with clients.
|
4
|
+
#
|
5
|
+
# Connectable servers check clients for incoming data on each tick.
|
6
|
+
#
|
3
7
|
module Connectable
|
4
8
|
def tick
|
5
9
|
clients.each do |client|
|
@@ -16,6 +20,7 @@ module Backport
|
|
16
20
|
clients.map(&:stop)
|
17
21
|
end
|
18
22
|
|
23
|
+
# @return [Array<Client>]
|
19
24
|
def clients
|
20
25
|
@clients ||= []
|
21
26
|
end
|
@@ -1,16 +1,25 @@
|
|
1
1
|
module Backport
|
2
2
|
module Server
|
3
|
+
# A Backport periodical interval server.
|
4
|
+
#
|
3
5
|
class Interval < Base
|
6
|
+
# @param period [Float] The interval time in seconds.
|
7
|
+
# @param block [Proc] The proc to run on each interval.
|
8
|
+
# @yieldparam [Interval]
|
4
9
|
def initialize period, &block
|
5
10
|
@period = period
|
6
11
|
@block = block
|
7
12
|
@last_time = Time.now
|
8
13
|
end
|
9
14
|
|
15
|
+
def starting
|
16
|
+
@last_time = Time.now
|
17
|
+
end
|
18
|
+
|
10
19
|
def tick
|
11
20
|
now = Time.now
|
12
|
-
return unless now - @last_time
|
13
|
-
@block.call
|
21
|
+
return unless now - @last_time >= @period
|
22
|
+
@block.call self
|
14
23
|
@last_time = now
|
15
24
|
end
|
16
25
|
end
|
@@ -2,11 +2,14 @@ require 'socket'
|
|
2
2
|
|
3
3
|
module Backport
|
4
4
|
module Server
|
5
|
+
# A Backport TCP server. It runs a thread to accept incoming connections
|
6
|
+
# and automatically stops when the socket is closed.
|
7
|
+
#
|
5
8
|
class Tcpip < Base
|
6
9
|
include Connectable
|
7
10
|
|
8
|
-
def initialize host: 'localhost', port: 1117, adapter: Adapter
|
9
|
-
@socket =
|
11
|
+
def initialize host: 'localhost', port: 1117, adapter: Adapter, socket_class: TCPServer
|
12
|
+
@socket = socket_class.new(host, port)
|
10
13
|
@adapter = adapter
|
11
14
|
@stopped = false
|
12
15
|
end
|
@@ -31,16 +34,46 @@ module Backport
|
|
31
34
|
|
32
35
|
def stopping
|
33
36
|
super
|
37
|
+
return if socket.closed?
|
34
38
|
begin
|
35
|
-
socket.shutdown
|
36
|
-
rescue Errno::ENOTCONN, IOError
|
37
|
-
#
|
39
|
+
socket.shutdown Socket::SHUT_RDWR
|
40
|
+
rescue Errno::ENOTCONN, IOError => e
|
41
|
+
Backport.logger.info "Minor exception while stopping server [#{e.class}] #{e.message}"
|
38
42
|
end
|
39
43
|
socket.close
|
40
44
|
end
|
41
45
|
|
42
|
-
|
43
|
-
|
46
|
+
# Accept an incoming connection using accept_nonblock. Return the
|
47
|
+
# resulting Client if a connection was accepted or nil if no connections
|
48
|
+
# are pending.
|
49
|
+
#
|
50
|
+
# @return [Client, nil]
|
51
|
+
def accept
|
52
|
+
result = nil
|
53
|
+
mutex.synchronize do
|
54
|
+
begin
|
55
|
+
conn = socket.accept_nonblock
|
56
|
+
addr = conn.addr(true)
|
57
|
+
data = {
|
58
|
+
family: addr[0],
|
59
|
+
port: addr[1],
|
60
|
+
hostname: addr[2],
|
61
|
+
address: addr[3]
|
62
|
+
}
|
63
|
+
clients.push Client.new(conn, conn, @adapter, data)
|
64
|
+
clients.last.run
|
65
|
+
result = clients.last
|
66
|
+
rescue IO::WaitReadable, Errno::EAGAIN => e
|
67
|
+
# ignore
|
68
|
+
rescue Errno::ENOTSOCK, IOError => e
|
69
|
+
Backport.logger.info "Server stopped with minor exception [#{e.class}] #{e.message}"
|
70
|
+
stop
|
71
|
+
rescue Exception => e
|
72
|
+
Backport.logger.warn "Server stopped with major exception [#{e.class}] #{e.message}"
|
73
|
+
stop
|
74
|
+
end
|
75
|
+
end
|
76
|
+
result
|
44
77
|
end
|
45
78
|
|
46
79
|
private
|
@@ -55,24 +88,10 @@ module Backport
|
|
55
88
|
def start_accept_thread
|
56
89
|
Thread.new do
|
57
90
|
until stopped?
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
data = {
|
63
|
-
family: addr[0],
|
64
|
-
port: addr[1],
|
65
|
-
hostname: addr[2],
|
66
|
-
address: addr[3]
|
67
|
-
}
|
68
|
-
clients.push Client.new(conn, conn, @adapter, data)
|
69
|
-
clients.last.run
|
70
|
-
end
|
71
|
-
rescue Exception => e
|
72
|
-
STDERR.puts "Server stopped with exception [#{e.class}] #{e.message}"
|
73
|
-
stop
|
74
|
-
break
|
75
|
-
end
|
91
|
+
client = accept
|
92
|
+
Backport.logger.info "Client connected: #{client.adapter.remote}" unless client.nil?
|
93
|
+
sleep 0.01
|
94
|
+
stop if socket.closed?
|
76
95
|
end
|
77
96
|
end
|
78
97
|
end
|
data/lib/backport/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
module Backport
|
2
|
-
VERSION =
|
3
|
-
end
|
1
|
+
module Backport
|
2
|
+
VERSION = '0.3.0'.freeze
|
3
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: backport
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Fred Snyder
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2019-01-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -75,6 +75,7 @@ extra_rdoc_files: []
|
|
75
75
|
files:
|
76
76
|
- ".gitignore"
|
77
77
|
- ".rspec"
|
78
|
+
- ".rubocop.yml"
|
78
79
|
- ".travis.yml"
|
79
80
|
- CHANGELOG.md
|
80
81
|
- Gemfile
|