roda 3.27.0 → 3.28.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|