fbe 0.19.3 → 0.21.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df9755e6d4d7bf5ae3a716031216786929ddfcdf328a3f026b308e9bbfc93f92
4
- data.tar.gz: ed1cfe9f9877a3e3e1b77f30f4b9c8ca525885306d100058d5397019ca1005e6
3
+ metadata.gz: ae9a9de76ff2dba753f10923fb0f984acc0c0e2e4f2a626a15c0ae6622d64392
4
+ data.tar.gz: ceeef4187f94f32b39569cf7f1e98c1a25559b1730c742f8c01073520456afce
5
5
  SHA512:
6
- metadata.gz: 3bfc015d2f0c47035fae903627404da6fafcd823c14abdcdc8a37694fea73bbb4edd1f1467497379fff51e62388b741a205b28bcd0ee8a4b5c28aceaf0f6c22c
7
- data.tar.gz: dc92c946e0d1bd14deb3d29a73b36f9317d7e06597ac2ec4fc05030ed9afea415830a71f101eb54ddd2793a02a3c6b480d2a2b95482f3deb0d62903230b1432e
6
+ metadata.gz: c2cec9308283d6d2ced937184b6e62b09bee329a10b7d65d69d369002dfb797b7e2b513611d64911d09cbaa50b7c00aec6480040f5955510297a20128528b443
7
+ data.tar.gz: 4d901bf3b1a062683dd481398e4961447676af8ae97ef8d938b321c6b0774fdadde71ba69e4875859e138e1853be2f1a734db1bf0fbe72f5ea62695c67fb67e3
data/Gemfile CHANGED
@@ -8,6 +8,7 @@ gemspec
8
8
 
9
9
  gem 'minitest', '~>5.25', require: false
10
10
  gem 'minitest-reporters', '~>1.7', require: false
11
+ gem 'minitest-stub-const', '~>0.6', require: false
11
12
  gem 'os', '~>1.1', require: false
12
13
  gem 'qbash', '~>0.4', require: false
13
14
  gem 'rake', '~>13.3', require: false
data/Gemfile.lock CHANGED
@@ -143,6 +143,7 @@ GEM
143
143
  builder
144
144
  minitest (>= 5.0)
145
145
  ruby-progressbar
146
+ minitest-stub-const (0.6)
146
147
  moments (0.3.0)
147
148
  multipart-post (2.4.1)
148
149
  net-http (0.6.0)
@@ -179,7 +180,7 @@ GEM
179
180
  regexp_parser (2.10.0)
180
181
  retries (0.0.5)
181
182
  rexml (3.4.1)
182
- rubocop (1.76.0)
183
+ rubocop (1.76.1)
183
184
  json (~> 2.3)
184
185
  language_server-protocol (~> 3.17.0.2)
185
186
  lint_roller (~> 1.1.0)
@@ -190,7 +191,7 @@ GEM
190
191
  rubocop-ast (>= 1.45.0, < 2.0)
191
192
  ruby-progressbar (~> 1.7)
192
193
  unicode-display_width (>= 2.4.0, < 4.0)
193
- rubocop-ast (1.45.0)
194
+ rubocop-ast (1.45.1)
194
195
  parser (>= 3.3.7.2)
195
196
  prism (~> 1.4)
196
197
  rubocop-minitest (0.38.1)
@@ -258,6 +259,7 @@ DEPENDENCIES
258
259
  fbe!
259
260
  minitest (~> 5.25)
260
261
  minitest-reporters (~> 1.7)
262
+ minitest-stub-const (~> 0.6)
261
263
  os (~> 1.1)
262
264
  qbash (~> 0.4)
263
265
  rake (~> 13.3)
@@ -3,55 +3,119 @@
3
3
  # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Zerocracy
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
+ require 'time'
6
7
  require 'json'
7
8
  require 'sqlite3'
9
+ require 'loog'
8
10
  require_relative '../../fbe'
9
11
  require_relative '../../fbe/middleware'
10
12
 
11
13
  # Persisted SQLite store for Faraday::HttpCache
12
14
  #
15
+ # This class provides a persistent cache store backed by SQLite for use with
16
+ # Faraday::HttpCache middleware. It's designed to cache HTTP responses from
17
+ # GitHub API calls to reduce API rate limit consumption and improve performance.
18
+ #
19
+ # Key features:
20
+ # - Automatic version management to invalidate cache on version changes
21
+ # - Size-based cache eviction (configurable, defaults to 10MB)
22
+ # - Thread-safe SQLite transactions
23
+ # - JSON serialization for cached values
24
+ # - Filtering of non-cacheable requests (non-GET, URLs with query parameters)
25
+ #
26
+ # Usage example:
27
+ # store = Fbe::Middleware::SqliteStore.new(
28
+ # '/path/to/cache.db',
29
+ # '1.0.0',
30
+ # loog: logger,
31
+ # maxsize: 50 * 1024 * 1024 # 50MB max size
32
+ # )
33
+ #
34
+ # # Use with Faraday
35
+ # Faraday.new do |builder|
36
+ # builder.use Faraday::HttpCache, store: store
37
+ # end
38
+ #
39
+ # The store automatically manages the SQLite database schema and handles
40
+ # cleanup operations when the database grows too large. Old entries are
41
+ # deleted based on their last access time to maintain the configured size limit.
42
+ #
13
43
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
14
44
  # Copyright:: Copyright (c) 2024-2025 Zerocracy
15
45
  # License:: MIT
16
46
  class Fbe::Middleware::SqliteStore
17
47
  attr_reader :path
18
48
 
19
- def initialize(path)
49
+ # Initialize the SQLite store.
50
+ # @param path [String] Path to the SQLite database file
51
+ # @param version [String] Version identifier for cache compatibility
52
+ # @param loog [Loog] Logger instance (optional, defaults to Loog::NULL)
53
+ # @param maxsize [Integer] Maximum database size in bytes (optional, defaults to 10MB)
54
+ # @raise [ArgumentError] If path is nil/empty, directory doesn't exist, or version is nil/empty
55
+ def initialize(path, version, loog: Loog::NULL, maxsize: 10 * 1024 * 1024)
20
56
  raise ArgumentError, 'Database path cannot be nil or empty' if path.nil? || path.empty?
21
57
  dir = File.dirname(path)
22
58
  raise ArgumentError, "Directory #{dir} does not exist" unless File.directory?(dir)
59
+ raise ArgumentError, 'Version cannot be nil or empty' if version.nil? || version.empty?
23
60
  @path = File.absolute_path(path)
61
+ @version = version
62
+ @loog = loog
63
+ @maxsize = maxsize
24
64
  end
25
65
 
66
+ # Read a value from the cache.
67
+ # @param key [String] The cache key to read
68
+ # @return [Object, nil] The cached value parsed from JSON, or nil if not found
26
69
  def read(key)
27
- value = perform { _1.execute('SELECT value FROM cache WHERE key = ? LIMIT 1', [key]) }.dig(0, 0)
70
+ value = perform do |t|
71
+ t.execute('UPDATE cache SET touched_at = ?2 WHERE key = ?1;', [key, Time.now.utc.iso8601])
72
+ t.execute('SELECT value FROM cache WHERE key = ? LIMIT 1;', [key])
73
+ end.dig(0, 0)
28
74
  JSON.parse(value) if value
29
75
  end
30
76
 
77
+ # Delete a key from the cache.
78
+ # @param key [String] The cache key to delete
79
+ # @return [nil]
31
80
  def delete(key)
32
81
  perform { _1.execute('DELETE FROM cache WHERE key = ?', [key]) }
33
82
  nil
34
83
  end
35
84
 
85
+ # Write a value to the cache.
86
+ # @param key [String] The cache key to write
87
+ # @param value [Object] The value to cache (will be JSON encoded)
88
+ # @return [nil]
89
+ # @note Values larger than 10KB are not cached
90
+ # @note Non-GET requests and URLs with query parameters are not cached
36
91
  def write(key, value)
37
92
  return if value.is_a?(Array) && value.any? do |vv|
38
93
  req = JSON.parse(vv[0])
39
94
  req['url'].include?('?') || req['method'] != 'get'
40
95
  end
41
96
  value = JSON.dump(value)
97
+ return if value.bytesize > 10_000
42
98
  perform do |t|
43
- t.execute(<<~SQL, [key, value])
44
- INSERT INTO cache(key, value) VALUES(?1, ?2)
45
- ON CONFLICT(key) DO UPDATE SET value = ?2
99
+ t.execute(<<~SQL, [key, value, Time.now.utc.iso8601])
100
+ INSERT INTO cache(key, value, touched_at) VALUES(?1, ?2, ?3)
101
+ ON CONFLICT(key) DO UPDATE SET value = ?2, touched_at = ?3
46
102
  SQL
47
103
  end
48
104
  nil
49
105
  end
50
106
 
107
+ # Clear all entries from the cache.
108
+ # @return [void]
51
109
  def clear
52
- perform { _1.execute 'DELETE FROM cache;' }
110
+ perform do |t|
111
+ t.execute 'DELETE FROM cache;'
112
+ t.execute "UPDATE meta SET value = ? WHERE key = 'version';", [@version]
113
+ end
114
+ @db.execute 'VACUUM;'
53
115
  end
54
116
 
117
+ # Get all entries from the cache.
118
+ # @return [Array<Array>] Array of [key, value] pairs
55
119
  def all
56
120
  perform { _1.execute('SELECT key, value FROM cache') }
57
121
  end
@@ -62,8 +126,46 @@ class Fbe::Middleware::SqliteStore
62
126
  @db ||=
63
127
  SQLite3::Database.new(@path).tap do |d|
64
128
  d.transaction do |t|
65
- t.execute 'CREATE TABLE IF NOT EXISTS cache(key TEXT UNIQUE NOT NULL, value TEXT);'
66
- t.execute 'CREATE INDEX IF NOT EXISTS key_idx ON cache(key);'
129
+ t.execute <<~SQL
130
+ CREATE TABLE IF NOT EXISTS cache(
131
+ key TEXT UNIQUE NOT NULL, value TEXT, touched_at TEXT NOT NULL
132
+ );
133
+ SQL
134
+ t.execute 'CREATE INDEX IF NOT EXISTS cache_key_idx ON cache(key);'
135
+ t.execute 'CREATE INDEX IF NOT EXISTS cache_touched_at_idx ON cache(touched_at);'
136
+ t.execute 'CREATE TABLE IF NOT EXISTS meta(key TEXT UNIQUE NOT NULL, value TEXT);'
137
+ t.execute 'CREATE INDEX IF NOT EXISTS meta_key_idx ON meta(key);'
138
+ t.execute "INSERT INTO meta(key, value) VALUES('version', ?) ON CONFLICT(key) DO NOTHING;", [@version]
139
+ end
140
+ found = d.execute("SELECT value FROM meta WHERE key = 'version' LIMIT 1;").dig(0, 0)
141
+ if found != @version
142
+ @loog.info("Version mismatch in SQLite cache: stored '#{found}' != current '#{@version}', cleaning up")
143
+ d.transaction do |t|
144
+ t.execute 'DELETE FROM cache;'
145
+ t.execute "UPDATE meta SET value = ? WHERE key = 'version';", [@version]
146
+ end
147
+ d.execute 'VACUUM;'
148
+ end
149
+ if File.size(@path) > @maxsize
150
+ @loog.info(
151
+ "SQLite cache file size (#{File.size(@path)} bytes) exceeds " \
152
+ "#{@maxsize / 1024 / 1024}MB, cleaning up old entries"
153
+ )
154
+ deleted = 0
155
+ while d.execute(<<~SQL).dig(0, 0) > @maxsize
156
+ SELECT (page_count - freelist_count) * page_size AS size
157
+ FROM pragma_page_count(), pragma_freelist_count(), pragma_page_size();
158
+ SQL
159
+ d.transaction do |t|
160
+ t.execute <<~SQL
161
+ DELETE FROM cache
162
+ WHERE key IN (SELECT key FROM cache ORDER BY touched_at LIMIT 50)
163
+ SQL
164
+ deleted += t.changes
165
+ end
166
+ end
167
+ d.execute 'VACUUM;'
168
+ @loog.info("Deleted #{deleted} old cache entries, new file size: #{File.size(@path)} bytes")
67
169
  end
68
170
  at_exit { @db&.close }
69
171
  end
data/lib/fbe/octo.rb CHANGED
@@ -25,6 +25,10 @@ require_relative 'middleware/sqlite_store'
25
25
  # logging, and caching.
26
26
  #
27
27
  # @param [Judges::Options] options The options available globally
28
+ # @option options [String] :github_token GitHub API token for authentication
29
+ # @option options [Boolean] :testing When true, uses FakeOctokit for testing
30
+ # @option options [String] :sqlite_cache Path to SQLite cache file for HTTP responses
31
+ # @option options [Integer] :sqlite_cache_maxsize Maximum size of SQLite cache in bytes (default: 10MB)
28
32
  # @param [Hash] global Hash of global options
29
33
  # @param [Loog] loog Logging facility
30
34
  # @return [Hash] Usually returns a JSON, as it comes from the GitHub API
@@ -77,11 +81,12 @@ def Fbe.octo(options: $options, global: $global, loog: $loog)
77
81
  backoff_factor: 2
78
82
  )
79
83
  if options.sqlite_cache
80
- store = Fbe::Middleware::SqliteStore.new(options.sqlite_cache)
84
+ maxsize = options.sqlite_cache_maxsize || (10 * 1024 * 1024)
85
+ store = Fbe::Middleware::SqliteStore.new(options.sqlite_cache, Fbe::VERSION, loog:, maxsize:)
81
86
  loog.info(
82
87
  "Using HTTP cache in SQLite file: #{store.path} (" \
83
- "#{File.exist?(store.path) ? "#{File.size(store.path)} bytes" : 'file is absent'}" \
84
- ')'
88
+ "#{File.exist?(store.path) ? "#{File.size(store.path)} bytes" : 'file is absent'}, " \
89
+ "max size: #{maxsize / 1024 / 1024}MB)"
85
90
  )
86
91
  builder.use(
87
92
  Faraday::HttpCache,
data/lib/fbe.rb CHANGED
@@ -10,5 +10,5 @@
10
10
  # License:: MIT
11
11
  module Fbe
12
12
  # Current version of the gem (changed by +.rultor.yml+ on every release)
13
- VERSION = '0.19.3' unless const_defined?(:VERSION)
13
+ VERSION = '0.21.0' unless const_defined?(:VERSION)
14
14
  end
@@ -15,7 +15,7 @@ require_relative '../../../lib/fbe/middleware/sqlite_store'
15
15
  class SqliteStoreTest < Fbe::Test
16
16
  def test_simple_caching_algorithm
17
17
  with_tmpfile('x.db') do |f|
18
- store = Fbe::Middleware::SqliteStore.new(f)
18
+ store = Fbe::Middleware::SqliteStore.new(f, '0.0.0')
19
19
  k = 'some-key'
20
20
  assert_nil(store.read(k))
21
21
  assert_nil(store.delete(k))
@@ -33,14 +33,14 @@ class SqliteStoreTest < Fbe::Test
33
33
 
34
34
  def test_returns_empty_list
35
35
  with_tmpfile('b.db') do |f|
36
- store = Fbe::Middleware::SqliteStore.new(f)
36
+ store = Fbe::Middleware::SqliteStore.new(f, '0.0.0')
37
37
  assert_empty(store.all)
38
38
  end
39
39
  end
40
40
 
41
41
  def test_clear_all_keys
42
42
  with_tmpfile('a.db') do |f|
43
- store = Fbe::Middleware::SqliteStore.new(f)
43
+ store = Fbe::Middleware::SqliteStore.new(f, '0.0.0')
44
44
  k = 'a key'
45
45
  store.write(k, 'some value')
46
46
  store.clear
@@ -50,20 +50,20 @@ class SqliteStoreTest < Fbe::Test
50
50
 
51
51
  def test_empty_all_if_not_written
52
52
  with_tmpfile do |f|
53
- store = Fbe::Middleware::SqliteStore.new(f)
53
+ store = Fbe::Middleware::SqliteStore.new(f, '0.0.0')
54
54
  assert_empty(store.all)
55
55
  end
56
56
  end
57
57
 
58
58
  def test_wrong_db_path
59
59
  assert_raises(ArgumentError) do
60
- Fbe::Middleware::SqliteStore.new(nil).read('my_key')
60
+ Fbe::Middleware::SqliteStore.new(nil, '0.0.0').read('my_key')
61
61
  end
62
62
  assert_raises(ArgumentError) do
63
- Fbe::Middleware::SqliteStore.new('').read('my_key')
63
+ Fbe::Middleware::SqliteStore.new('', '0.0.0').read('my_key')
64
64
  end
65
65
  assert_raises(ArgumentError) do
66
- Fbe::Middleware::SqliteStore.new('/fakepath/fakefolder/test.db').read('my_key')
66
+ Fbe::Middleware::SqliteStore.new('/fakepath/fakefolder/test.db', '0.0.0').read('my_key')
67
67
  end
68
68
  end
69
69
 
@@ -72,7 +72,7 @@ class SqliteStoreTest < Fbe::Test
72
72
  File.binwrite(f, Array.new(20) { rand(0..255) }.pack('C*'))
73
73
  ex =
74
74
  assert_raises(SQLite3::NotADatabaseException) do
75
- Fbe::Middleware::SqliteStore.new(f).read('my_key')
75
+ Fbe::Middleware::SqliteStore.new(f, '0.0.0').read('my_key')
76
76
  end
77
77
  assert_match('file is not a database', ex.message)
78
78
  end
@@ -94,7 +94,7 @@ class SqliteStoreTest < Fbe::Test
94
94
  end
95
95
 
96
96
  Tempfile.open('test.db') do |f|
97
- Fbe::Middleware::SqliteStore.new(f.path).then do |s|
97
+ Fbe::Middleware::SqliteStore.new(f.path, '0.0.0').then do |s|
98
98
  s.write('my_key', 'my_value')
99
99
  s.read('my_key')
100
100
  end
@@ -109,6 +109,79 @@ class SqliteStoreTest < Fbe::Test
109
109
  assert_match('closed sqlite after process exit', out)
110
110
  end
111
111
 
112
+ def test_different_versions
113
+ with_tmpfile('d.db') do |f|
114
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
115
+ store.write('kkk1', 'some value')
116
+ store.write('kkk2', 'another value')
117
+ end
118
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
119
+ assert_equal('some value', store.read('kkk1'))
120
+ assert_equal('another value', store.read('kkk2'))
121
+ end
122
+ Fbe::Middleware::SqliteStore.new(f, '0.0.2').then do |store|
123
+ assert_nil(store.read('kkk1'))
124
+ assert_nil(store.read('kkk2'))
125
+ end
126
+ end
127
+ end
128
+
129
+ def test_initialize_wrong_version
130
+ with_tmpfile('e.db') do |f|
131
+ msg = 'Version cannot be nil or empty'
132
+ assert_raises(ArgumentError) { Fbe::Middleware::SqliteStore.new(f, nil) }.then do |ex|
133
+ assert_match(msg, ex.message)
134
+ end
135
+ assert_raises(ArgumentError) { Fbe::Middleware::SqliteStore.new(f, '') }.then do |ex|
136
+ assert_match(msg, ex.message)
137
+ end
138
+ end
139
+ end
140
+
141
+ def test_skip_write_if_value_more_then_10k_bytes
142
+ with_tmpfile('a.db') do |f|
143
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
144
+ store.write('a', 'a' * 9_997)
145
+ store.write('b', 'b' * 9_998)
146
+ store.write('c', 'c' * 9_999)
147
+ store.write('d', 'd' * 10_000)
148
+ assert_equal('a' * 9_997, store.read('a'))
149
+ assert_equal('b' * 9_998, store.read('b'))
150
+ assert_nil(store.read('c'))
151
+ assert_nil(store.read('d'))
152
+ end
153
+ end
154
+ end
155
+
156
+ def test_shrink_cache_if_more_then_10_mb
157
+ with_tmpfile('large.db') do |f|
158
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
159
+ key = 'aaa'
160
+ alpha = ('a'..'z').to_a
161
+ store.write('a', 'aa')
162
+ Time.stub(:now, (Time.now - (5 * 60 * 60)).round) do
163
+ store.write('b', 'bb')
164
+ store.write('c', 'cc')
165
+ end
166
+ assert_equal('cc', store.read('c'))
167
+ Time.stub(:now, rand((Time.now - (5 * 60 * 60))..Time.now).round) do
168
+ 10_240.times do
169
+ value = alpha.sample * rand(1024..2048)
170
+ store.write(key, value)
171
+ key = key.next
172
+ end
173
+ end
174
+ end
175
+ assert_operator(File.size(f), :>, 10 * 1024 * 1024)
176
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
177
+ assert_equal('aa', store.read('a'))
178
+ assert_nil(store.read('b'))
179
+ assert_equal('cc', store.read('c'))
180
+ assert_operator(File.size(f), :<=, 10 * 1024 * 1024)
181
+ end
182
+ end
183
+ end
184
+
112
185
  private
113
186
 
114
187
  def with_tmpfile(name = 'test.db', &)
@@ -405,4 +405,45 @@ class TestOcto < Fbe::Test
405
405
  assert_path_exists(file)
406
406
  end
407
407
  end
408
+
409
+ def test_sqlite_store_for_use_in_different_versions
410
+ WebMock.disable_net_connect!
411
+ Dir.mktmpdir do |dir|
412
+ global = {}
413
+ stub =
414
+ stub_request(:get, 'https://api.github.com/user/42')
415
+ .to_return(
416
+ status: 200,
417
+ body: { login: 'user1' }.to_json,
418
+ headers: {
419
+ 'Content-Type' => 'application/json',
420
+ 'Cache-Control' => 'public, max-age=60, s-maxage=60',
421
+ 'Etag' => 'W/"2ff9dd4c3153f006830b2b8b721f6a4bb400a1eb81a2e1fa0a3b846ad349b9ec"',
422
+ 'Last-Modified' => 'Wed, 01 May 2025 20:00:00 GMT'
423
+ }
424
+ )
425
+ sqlite_cache = File.expand_path('test.db', dir)
426
+ Fbe.stub_const(:VERSION, '0.0.1') do
427
+ o = Fbe.octo(loog: Loog::NULL, global:, options: Judges::Options.new({ 'sqlite_cache' => sqlite_cache }))
428
+ assert_equal('user1', o.user_name_by_id(42))
429
+ end
430
+ WebMock.remove_request_stub(stub)
431
+ stub_request(:get, 'https://api.github.com/user/42')
432
+ .to_return(
433
+ status: 200,
434
+ body: { login: 'user2' }.to_json,
435
+ headers: {
436
+ 'Content-Type' => 'application/json',
437
+ 'Cache-Control' => 'public, max-age=60, s-maxage=60',
438
+ 'Etag' => 'W/"2ff9dd4c3153f006830b2b8b721f6a4bb400a1eb81a2e1fa0a3b846ad349b9ec"',
439
+ 'Last-Modified' => 'Wed, 01 May 2025 20:00:00 GMT'
440
+ }
441
+ )
442
+ global = {}
443
+ Fbe.stub_const(:VERSION, '0.0.2') do
444
+ o = Fbe.octo(loog: Loog::NULL, global:, options: Judges::Options.new({ 'sqlite_cache' => sqlite_cache }))
445
+ assert_equal('user2', o.user_name_by_id(42))
446
+ end
447
+ end
448
+ end
408
449
  end
data/test/test__helper.rb CHANGED
@@ -31,6 +31,7 @@ end
31
31
  require 'minitest/reporters'
32
32
  Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new]
33
33
 
34
+ require 'minitest/stub_const'
34
35
  require 'minitest/autorun'
35
36
  require 'webmock/minitest'
36
37
  require_relative '../lib/fbe'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fbe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.3
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko