roda 3.27.0 → 3.28.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/CHANGELOG +8 -0
- data/MIT-LICENSE +1 -1
- data/Rakefile +5 -0
- data/doc/release_notes/3.28.0.txt +13 -0
- data/lib/roda.rb +5 -3
- data/lib/roda/plugins/assets.rb +1 -1
- data/lib/roda/plugins/default_status.rb +1 -1
- data/lib/roda/plugins/json_parser.rb +1 -0
- data/lib/roda/plugins/mail_processor.rb +1 -1
- data/lib/roda/plugins/render.rb +2 -0
- data/lib/roda/plugins/sessions.rb +17 -3
- data/lib/roda/version.rb +1 -1
- data/spec/define_roda_method_spec.rb +2 -2
- data/spec/plugin/common_logger_spec.rb +7 -7
- data/spec/plugin/json_parser_spec.rb +8 -0
- data/spec/plugin/sessions_spec.rb +19 -6
- data/spec/plugin/streaming_spec.rb +2 -2
- data/spec/session_spec.rb +3 -3
- data/spec/spec_helper.rb +1 -0
- metadata +5 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2de9e0d72db9d2f1d1143c71738067798dbcc3f2786611f8f0a5f85c32f26167
|
4
|
+
data.tar.gz: 92645c4e9792b1217cfae3d8b430741f64212a30003cf81b393c37636dc531c0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 35a615a610ca645130cbd15482f6864a50a3a8727e6e4cf7bad8d63a00e40118f7a0443f88931ec24a5f416ae822a9bd8ebc101f930efff178831660d5125849
|
7
|
+
data.tar.gz: a6b9f48cfe249297ce8abfd3a856c65634364ea0490910b956f25fe4cd54c4b77096bd980a59276e6ed0fb4db537e99fa8b4e1e0e35441afaaf942ca7e92bb02
|
data/CHANGELOG
CHANGED
@@ -1,3 +1,11 @@
|
|
1
|
+
= 3.28.0 (2020-01-15)
|
2
|
+
|
3
|
+
* Add session_created_at and session_updated_at methods to the sessions plugin (jeremyevans)
|
4
|
+
|
5
|
+
* Make upgrading from rack session cookie in sessions plugin work with rack 2.0.8 (jeremyevans)
|
6
|
+
|
7
|
+
* Make json_parser parse request body as json even if request body has already been read (jeremyevans)
|
8
|
+
|
1
9
|
= 3.27.0 (2019-12-13)
|
2
10
|
|
3
11
|
* Allow json_parser return correct result for invalid JSON if the params_capturing plugin is used (jeremyevans) (#180)
|
data/MIT-LICENSE
CHANGED
data/Rakefile
CHANGED
@@ -77,6 +77,11 @@ task "spec_cov" do
|
|
77
77
|
spec.call('COVERAGE'=>'1')
|
78
78
|
end
|
79
79
|
|
80
|
+
desc "Run specs with branch coverage"
|
81
|
+
task "spec_branch_cov" do
|
82
|
+
spec.call('COVERAGE'=>'1', 'BRANCH_COVERAGE'=>'1')
|
83
|
+
end
|
84
|
+
|
80
85
|
desc "Run specs with -w, some warnings filtered"
|
81
86
|
task "spec_w" do
|
82
87
|
rubyopt = ENV['RUBYOPT']
|
@@ -0,0 +1,13 @@
|
|
1
|
+
= New Features
|
2
|
+
|
3
|
+
* The sessions plugin now supports RodaRequest#session_created_at
|
4
|
+
and RodaRequest#session_updated_at for the times of session
|
5
|
+
creation and last update.
|
6
|
+
|
7
|
+
= Other Improvements
|
8
|
+
|
9
|
+
* The json_parser plugin now correctly parses the request body even
|
10
|
+
if the request body has already been read.
|
11
|
+
|
12
|
+
* The sessions plugin now correctly handles upgrading rack cookie
|
13
|
+
sessions when using rack 2.0.8+.
|
data/lib/roda.rb
CHANGED
@@ -125,10 +125,12 @@ class Roda
|
|
125
125
|
# Complexity of handling keyword arguments using define_method is too high,
|
126
126
|
# Fallback to instance_exec in this case.
|
127
127
|
b = block
|
128
|
-
if RUBY_VERSION >= '2.7'
|
129
|
-
|
128
|
+
block = if RUBY_VERSION >= '2.7'
|
129
|
+
eval('lambda{|*a, **kw| instance_exec(*a, **kw, &b)}', nil, __FILE__, __LINE__) # Keyword arguments fallback
|
130
130
|
else
|
131
|
-
|
131
|
+
# :nocov:
|
132
|
+
lambda{|*a| instance_exec(*a, &b)} # Keyword arguments fallback
|
133
|
+
# :nocov:
|
132
134
|
end
|
133
135
|
else
|
134
136
|
arity_meth = meth
|
data/lib/roda/plugins/assets.rb
CHANGED
@@ -297,7 +297,7 @@ class Roda
|
|
297
297
|
# can use nil to disable subresource integrity.
|
298
298
|
# :timestamp_paths :: Include the timestamp of assets in asset paths in non-compiled mode. Doing this can
|
299
299
|
# slow down development requests due to additional requests to get last modified times,
|
300
|
-
#
|
300
|
+
# but it will make sure the paths change in development when there are modifications,
|
301
301
|
# which can fix issues when using a caching proxy in non-compiled mode. This can also
|
302
302
|
# be specified as a string to use that string to separate the timestamp from the asset.
|
303
303
|
# By default, <tt>/</tt> is used as the separator if timestamp paths are enabled.
|
@@ -17,7 +17,7 @@ class Roda
|
|
17
17
|
module DefaultStatus
|
18
18
|
def self.configure(app, &block)
|
19
19
|
raise RodaError, "default_status plugin requires a block" unless block
|
20
|
-
|
20
|
+
if check_arity = app.opts.fetch(:check_arity, true)
|
21
21
|
unless block.arity == 0
|
22
22
|
if check_arity == :warn
|
23
23
|
RodaPlugins.warn "Arity mismatch in block passed to plugin :default_status. Expected Arity 0, but arguments required for #{block.inspect}"
|
@@ -332,7 +332,7 @@ class Roda
|
|
332
332
|
string_meth = nil
|
333
333
|
regexp_meth = nil
|
334
334
|
addresses.each do |address|
|
335
|
-
|
335
|
+
case address
|
336
336
|
when String
|
337
337
|
unless string_meth
|
338
338
|
string_meth = define_roda_method("mail_processor_string_route_#{address}", 1, &convert_route_block(block))
|
data/lib/roda/plugins/render.rb
CHANGED
@@ -143,9 +143,11 @@ class Roda
|
|
143
143
|
template.send(:compiled_method, locals_keys, scope_class)
|
144
144
|
end
|
145
145
|
else
|
146
|
+
# :nocov:
|
146
147
|
def self.tilt_template_compiled_method(template, locals_keys, scope_class)
|
147
148
|
template.send(:compiled_method, locals_keys)
|
148
149
|
end
|
150
|
+
# :nocov:
|
149
151
|
end
|
150
152
|
|
151
153
|
# Setup default rendering options. See Render for details.
|
@@ -172,7 +172,6 @@ class Roda
|
|
172
172
|
|
173
173
|
# Configure the plugin, see Sessions for details on options.
|
174
174
|
def self.configure(app, opts=OPTS)
|
175
|
-
plugin_opts = opts
|
176
175
|
opts = (app.opts[:sessions] || DEFAULT_OPTIONS).merge(opts)
|
177
176
|
co = opts[:cookie_options] = DEFAULT_COOKIE_OPTIONS.merge(opts[:cookie_options] || OPTS).freeze
|
178
177
|
opts[:remove_cookie_options] = co.merge(:max_age=>'0', :expires=>Time.at(0))
|
@@ -239,6 +238,18 @@ class Roda
|
|
239
238
|
@env['rack.session'] ||= _load_session
|
240
239
|
end
|
241
240
|
|
241
|
+
# The time the session was originally created. nil if there is no active session.
|
242
|
+
def session_created_at
|
243
|
+
session
|
244
|
+
Time.at(@env[SESSION_CREATED_AT]) if @env[SESSION_SERIALIZED]
|
245
|
+
end
|
246
|
+
|
247
|
+
# The time the session was last updated. nil if there is no active session.
|
248
|
+
def session_updated_at
|
249
|
+
session
|
250
|
+
Time.at(@env[SESSION_UPDATED_AT]) if @env[SESSION_SERIALIZED]
|
251
|
+
end
|
252
|
+
|
242
253
|
# Persist the session data as a cookie. If transparently upgrading from
|
243
254
|
# Rack::Session::Cookie, mark the related cookie for expiration so it isn't
|
244
255
|
# sent in the future.
|
@@ -296,8 +307,6 @@ class Roda
|
|
296
307
|
# hmac and coder.
|
297
308
|
def _deserialize_rack_session(data)
|
298
309
|
opts = roda_class.opts[:sessions]
|
299
|
-
key = opts[:upgrade_from_rack_session_cookie_key]
|
300
|
-
secret = opts[:upgrade_from_rack_session_cookie_secret]
|
301
310
|
data, digest = data.split("--", 2)
|
302
311
|
unless digest
|
303
312
|
return _session_serialization_error("Not decoding Rack::Session::Cookie session: invalid format")
|
@@ -315,6 +324,11 @@ class Roda
|
|
315
324
|
# Mark rack session cookie for deletion on success
|
316
325
|
env[SESSION_DELETE_RACK_COOKIE] = true
|
317
326
|
|
327
|
+
# Delete the session id before serializing it. Starting in rack 2.0.8,
|
328
|
+
# this is an object and not just a string, and calling to_s on it raises
|
329
|
+
# a RuntimeError.
|
330
|
+
session.delete("session_id")
|
331
|
+
|
318
332
|
# Convert the rack session by roundtripping it through
|
319
333
|
# the parser and serializer, so that you would get the
|
320
334
|
# same result as you would if the session was handled
|
data/lib/roda/version.rb
CHANGED
@@ -8,12 +8,12 @@ describe "Roda.define_roda_method" do
|
|
8
8
|
it "should define methods using block" do
|
9
9
|
m0 = app.define_roda_method("x", 0){1}
|
10
10
|
m0.must_be_kind_of Symbol
|
11
|
-
m0.must_match
|
11
|
+
m0.must_match(/\A_roda_x_\d+\z/)
|
12
12
|
@scope.send(m0).must_equal 1
|
13
13
|
|
14
14
|
m1 = app.define_roda_method("x", 1){|x| [x, 2]}
|
15
15
|
m1.must_be_kind_of Symbol
|
16
|
-
m1.must_match
|
16
|
+
m1.must_match(/\A_roda_x_\d+\z/)
|
17
17
|
@scope.send(m1, 3).must_equal [3, 2]
|
18
18
|
end
|
19
19
|
|
@@ -14,26 +14,26 @@ describe "common_logger plugin" do
|
|
14
14
|
|
15
15
|
body.must_equal '/'
|
16
16
|
@logger.rewind
|
17
|
-
@logger.read.must_match
|
17
|
+
@logger.read.must_match(/\A- - - \[\d\d\/[A-Z][a-z]{2}\/\d\d\d\d:\d\d:\d\d:\d\d [-+]\d\d\d\d\] "GET \/ " 200 1 0.\d\d\d\d\n\z/)
|
18
18
|
|
19
19
|
@logger.rewind
|
20
20
|
@logger.truncate(0)
|
21
21
|
body('', 'HTTP_X_FORWARDED_FOR'=>'1.1.1.1', 'REMOTE_USER'=>'je', 'REQUEST_METHOD'=>'POST', 'QUERY_STRING'=>'', "HTTP_VERSION"=>'HTTP/1.1').must_equal ''
|
22
22
|
@logger.rewind
|
23
|
-
@logger.read.must_match
|
23
|
+
@logger.read.must_match(/\A1\.1\.1\.1 - je \[\d\d\/[A-Z][a-z]{2}\/\d\d\d\d:\d\d:\d\d:\d\d [-+]\d\d\d\d\] "POST HTTP\/1.1" 200 - 0.\d\d\d\d\n\z/)
|
24
24
|
|
25
25
|
@logger.rewind
|
26
26
|
@logger.truncate(0)
|
27
27
|
body('/b', 'REMOTE_ADDR'=>'1.1.1.2', 'QUERY_STRING'=>'foo=bar', "HTTP_VERSION"=>'HTTP/1.0').must_equal '/b'
|
28
28
|
@logger.rewind
|
29
|
-
@logger.read.must_match
|
29
|
+
@logger.read.must_match(/\A1\.1\.1\.2 - - \[\d\d\/[A-Z][a-z]{2}\/\d\d\d\d:\d\d:\d\d:\d\d [-+]\d\d\d\d\] "GET \/b\?foo=bar HTTP\/1.0" 200 2 0.\d\d\d\d\n\z/)
|
30
30
|
|
31
31
|
@app.plugin :common_logger, Logger.new(@logger)
|
32
32
|
@logger.rewind
|
33
33
|
@logger.truncate(0)
|
34
34
|
body.must_equal '/'
|
35
35
|
@logger.rewind
|
36
|
-
@logger.read.must_match
|
36
|
+
@logger.read.must_match(/\A- - - \[\d\d\/[A-Z][a-z]{2}\/\d\d\d\d:\d\d:\d\d:\d\d [-+]\d\d\d\d\] "GET \/ " 200 1 0.\d\d\d\d\n\z/)
|
37
37
|
end
|
38
38
|
|
39
39
|
it 'skips timer information if not available' do
|
@@ -44,7 +44,7 @@ describe "common_logger plugin" do
|
|
44
44
|
|
45
45
|
body.must_equal '/'
|
46
46
|
@logger.rewind
|
47
|
-
@logger.read.must_match
|
47
|
+
@logger.read.must_match(/\A- - - \[\d\d\/[A-Z][a-z]{2}\/\d\d\d\d:\d\d:\d\d:\d\d [-+]\d\d\d\d\] "GET \/ " 200 1 -\n\z/)
|
48
48
|
end
|
49
49
|
|
50
50
|
it 'skips length information if not available' do
|
@@ -54,7 +54,7 @@ describe "common_logger plugin" do
|
|
54
54
|
|
55
55
|
body.must_equal ''
|
56
56
|
@logger.rewind
|
57
|
-
@logger.read.must_match
|
57
|
+
@logger.read.must_match(/\A- - - \[\d\d\/[A-Z][a-z]{2}\/\d\d\d\d:\d\d:\d\d:\d\d [-+]\d\d\d\d\] "GET \/ " 500 - 0.\d\d\d\d\n\z/)
|
58
58
|
end
|
59
59
|
|
60
60
|
it 'does not log if an error is raised' do
|
@@ -80,6 +80,6 @@ describe "common_logger plugin" do
|
|
80
80
|
|
81
81
|
body.must_equal 'bad'
|
82
82
|
@logger.rewind
|
83
|
-
@logger.read.must_match
|
83
|
+
@logger.read.must_match(/\A- - - \[\d\d\/[A-Z][a-z]{2}\/\d\d\d\d:\d\d:\d\d:\d\d [-+]\d\d\d\d\] "GET \/ " 500 3 0.\d\d\d\d\n\z/)
|
84
84
|
end
|
85
85
|
end
|
@@ -15,6 +15,14 @@ describe "json_parser plugin" do
|
|
15
15
|
body('rack.input'=>StringIO.new('a[b]=1'), 'REQUEST_METHOD'=>'POST').must_equal '1'
|
16
16
|
end
|
17
17
|
|
18
|
+
it "parses incoming json if content type specifies json and body is already read" do
|
19
|
+
@app.route do |r|
|
20
|
+
r.body.read
|
21
|
+
r.params['a']['b'].to_s
|
22
|
+
end
|
23
|
+
body('rack.input'=>StringIO.new('{"a":{"b":1}}'), 'CONTENT_TYPE'=>'text/json', 'REQUEST_METHOD'=>'POST').must_equal '1'
|
24
|
+
end
|
25
|
+
|
18
26
|
it "returns 400 for invalid json" do
|
19
27
|
req('rack.input'=>StringIO.new('{"a":{"b":1}'), 'CONTENT_TYPE'=>'text/json', 'REQUEST_METHOD'=>'POST').must_equal [400, {}, []]
|
20
28
|
end
|
@@ -28,8 +28,11 @@ if RUBY_VERSION >= '2'
|
|
28
28
|
r.get('g', String){|k| session[k].to_s}
|
29
29
|
r.get('sct'){|i| session; env['roda.session.created_at'].to_s}
|
30
30
|
r.get('ssct', Integer){|i| session; (env['roda.session.created_at'] -= i).to_s}
|
31
|
+
r.get('ssct2', Integer, String, String){|i, k, v| session[k] = v; (env['roda.session.created_at'] -= i).to_s}
|
31
32
|
r.get('sc'){session.clear; 'c'}
|
32
33
|
r.get('cs', String, String){|k, v| clear_session; session[k] = v}
|
34
|
+
r.get('cat'){t = r.session_created_at; t.strftime("%F") if t}
|
35
|
+
r.get('uat'){t = r.session_updated_at; t.strftime("%F") if t}
|
33
36
|
''
|
34
37
|
end
|
35
38
|
end
|
@@ -68,6 +71,16 @@ if RUBY_VERSION >= '2'
|
|
68
71
|
errors.must_equal []
|
69
72
|
end
|
70
73
|
|
74
|
+
it "allows accessing session creation and last update times" do
|
75
|
+
status('/cat').must_equal 404
|
76
|
+
status('/uat').must_equal 404
|
77
|
+
status('/s/foo/bar').must_equal 200
|
78
|
+
body('/cat').must_equal Date.today.strftime("%F")
|
79
|
+
body('/uat').must_equal Date.today.strftime("%F")
|
80
|
+
status('/ssct2/172800/bar/baz').must_equal 200
|
81
|
+
body('/cat').must_equal((Date.today - 2).strftime("%F"))
|
82
|
+
end
|
83
|
+
|
71
84
|
it "does not add Set-Cookie header if session does not change, unless outside :skip_within seconds" do
|
72
85
|
req('/').must_equal [200, {"Content-Type"=>"text/html", "Content-Length"=>"0"}, [""]]
|
73
86
|
_, h, b = req('/s/foo/bar')
|
@@ -97,16 +110,16 @@ if RUBY_VERSION >= '2'
|
|
97
110
|
|
98
111
|
it "removes session cookie when session is submitted but empty after request" do
|
99
112
|
body('/s/foo/bar').must_equal 'bar'
|
100
|
-
|
113
|
+
body('/sct').to_i
|
101
114
|
body('/g/foo').must_equal 'bar'
|
102
115
|
|
103
116
|
_, h, b = req('/sc')
|
104
117
|
|
105
118
|
# Parameters can come in any order, and only the final parameter may omit the ;
|
106
119
|
['roda.session=', 'max-age=0', 'path=/'].each do |param|
|
107
|
-
h['Set-Cookie'].must_match
|
120
|
+
h['Set-Cookie'].must_match(/#{Regexp.escape(param)}(;|\z)/)
|
108
121
|
end
|
109
|
-
h['Set-Cookie'].must_match
|
122
|
+
h['Set-Cookie'].must_match(/expires=Thu, 01 Jan 1970 00:00:00 (-0000|GMT)(;|\z)/)
|
110
123
|
|
111
124
|
b.must_equal ['c']
|
112
125
|
|
@@ -116,16 +129,16 @@ if RUBY_VERSION >= '2'
|
|
116
129
|
it "removes session cookie even when max-age and expires are in cookie options" do
|
117
130
|
app.plugin :sessions, :cookie_options=>{:max_age=>'1000', :expires=>Time.now+1000}
|
118
131
|
body('/s/foo/bar').must_equal 'bar'
|
119
|
-
|
132
|
+
body('/sct').to_i
|
120
133
|
body('/g/foo').must_equal 'bar'
|
121
134
|
|
122
135
|
_, h, b = req('/sc')
|
123
136
|
|
124
137
|
# Parameters can come in any order, and only the final parameter may omit the ;
|
125
138
|
['roda.session=', 'max-age=0', 'path=/'].each do |param|
|
126
|
-
h['Set-Cookie'].must_match
|
139
|
+
h['Set-Cookie'].must_match(/#{Regexp.escape(param)}(;|\z)/)
|
127
140
|
end
|
128
|
-
h['Set-Cookie'].must_match
|
141
|
+
h['Set-Cookie'].must_match(/expires=Thu, 01 Jan 1970 00:00:00 (-0000|GMT)(;|\z)/)
|
129
142
|
|
130
143
|
b.must_equal ['c']
|
131
144
|
|
@@ -216,7 +216,7 @@ describe "streaming plugin" do
|
|
216
216
|
end
|
217
217
|
end
|
218
218
|
|
219
|
-
|
219
|
+
req
|
220
220
|
q.deq
|
221
221
|
a.must_equal %w'a b c d e f g h i j'
|
222
222
|
end
|
@@ -238,7 +238,7 @@ describe "streaming plugin" do
|
|
238
238
|
end
|
239
239
|
end
|
240
240
|
|
241
|
-
|
241
|
+
req
|
242
242
|
q.deq
|
243
243
|
a.must_equal %w'a b c d e'
|
244
244
|
end
|
data/spec/session_spec.rb
CHANGED
@@ -27,11 +27,11 @@ describe "session handling" do
|
|
27
27
|
end
|
28
28
|
end
|
29
29
|
|
30
|
-
_,
|
30
|
+
_, _, b = req
|
31
31
|
b.join.must_equal 'ab'
|
32
|
-
_,
|
32
|
+
_, _, b = req
|
33
33
|
b.join.must_equal 'abb'
|
34
|
-
_,
|
34
|
+
_, _, b = req
|
35
35
|
b.join.must_equal 'abbb'
|
36
36
|
end
|
37
37
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -11,6 +11,7 @@ if ENV['COVERAGE']
|
|
11
11
|
|
12
12
|
def SimpleCov.roda_coverage(opts = {})
|
13
13
|
start do
|
14
|
+
enable_coverage :branch if ENV['BRANCH_COVERAGE']
|
14
15
|
add_filter "/spec/"
|
15
16
|
add_group('Missing'){|src| src.covered_percent < 100}
|
16
17
|
add_group('Covered'){|src| src.covered_percent == 100}
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: roda
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.28.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jeremy Evans
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2020-01-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rack
|
@@ -238,6 +238,7 @@ extra_rdoc_files:
|
|
238
238
|
- doc/release_notes/3.25.0.txt
|
239
239
|
- doc/release_notes/3.26.0.txt
|
240
240
|
- doc/release_notes/3.27.0.txt
|
241
|
+
- doc/release_notes/3.28.0.txt
|
241
242
|
files:
|
242
243
|
- CHANGELOG
|
243
244
|
- MIT-LICENSE
|
@@ -301,6 +302,7 @@ files:
|
|
301
302
|
- doc/release_notes/3.25.0.txt
|
302
303
|
- doc/release_notes/3.26.0.txt
|
303
304
|
- doc/release_notes/3.27.0.txt
|
305
|
+
- doc/release_notes/3.28.0.txt
|
304
306
|
- doc/release_notes/3.3.0.txt
|
305
307
|
- doc/release_notes/3.4.0.txt
|
306
308
|
- doc/release_notes/3.5.0.txt
|
@@ -578,7 +580,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
578
580
|
- !ruby/object:Gem::Version
|
579
581
|
version: '0'
|
580
582
|
requirements: []
|
581
|
-
rubygems_version: 3.
|
583
|
+
rubygems_version: 3.1.2
|
582
584
|
signing_key:
|
583
585
|
specification_version: 4
|
584
586
|
summary: Routing tree web toolkit
|