webmachine 1.2.2 → 1.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/CHANGELOG.md +4 -0
- data/Gemfile +13 -11
- data/README.md +85 -89
- data/Rakefile +0 -1
- data/documentation/adapters.md +39 -0
- data/documentation/authentication-and-authorization.md +37 -0
- data/documentation/configurator.md +19 -0
- data/documentation/error-handling.md +86 -0
- data/documentation/examples.md +215 -0
- data/documentation/how-it-works.md +76 -0
- data/documentation/routes.md +97 -0
- data/documentation/validation.md +159 -0
- data/documentation/versioning-apis.md +74 -0
- data/documentation/visual-debugger.md +38 -0
- data/examples/application.rb +2 -2
- data/examples/debugger.rb +1 -1
- data/lib/webmachine.rb +3 -1
- data/lib/webmachine/adapter.rb +7 -13
- data/lib/webmachine/adapters.rb +1 -2
- data/lib/webmachine/adapters/httpkit.rb +74 -0
- data/lib/webmachine/adapters/lazy_request_body.rb +1 -2
- data/lib/webmachine/adapters/rack.rb +37 -21
- data/lib/webmachine/adapters/reel.rb +21 -23
- data/lib/webmachine/adapters/webrick.rb +16 -16
- data/lib/webmachine/application.rb +2 -2
- data/lib/webmachine/chunked_body.rb +3 -4
- data/lib/webmachine/constants.rb +75 -0
- data/lib/webmachine/decision/conneg.rb +12 -10
- data/lib/webmachine/decision/flow.rb +31 -21
- data/lib/webmachine/decision/fsm.rb +10 -18
- data/lib/webmachine/decision/helpers.rb +9 -37
- data/lib/webmachine/dispatcher.rb +13 -10
- data/lib/webmachine/dispatcher/route.rb +18 -8
- data/lib/webmachine/errors.rb +7 -1
- data/lib/webmachine/header_negotiation.rb +25 -0
- data/lib/webmachine/headers.rb +7 -2
- data/lib/webmachine/locale/en.yml +7 -5
- data/lib/webmachine/media_type.rb +10 -8
- data/lib/webmachine/request.rb +44 -15
- data/lib/webmachine/resource.rb +1 -1
- data/lib/webmachine/resource/callbacks.rb +6 -4
- data/lib/webmachine/spec/IO_response.body +1 -0
- data/lib/webmachine/spec/adapter_lint.rb +70 -36
- data/lib/webmachine/spec/test_resource.rb +10 -4
- data/lib/webmachine/streaming/fiber_encoder.rb +1 -5
- data/lib/webmachine/streaming/io_encoder.rb +6 -0
- data/lib/webmachine/trace.rb +1 -0
- data/lib/webmachine/trace/fsm.rb +20 -10
- data/lib/webmachine/trace/resource_proxy.rb +2 -0
- data/lib/webmachine/translation.rb +2 -1
- data/lib/webmachine/version.rb +3 -3
- data/memory_test.rb +37 -0
- data/spec/spec_helper.rb +9 -9
- data/spec/webmachine/adapter_spec.rb +14 -15
- data/spec/webmachine/adapters/httpkit_spec.rb +10 -0
- data/spec/webmachine/adapters/rack_spec.rb +6 -6
- data/spec/webmachine/adapters/reel_spec.rb +15 -11
- data/spec/webmachine/adapters/webrick_spec.rb +2 -2
- data/spec/webmachine/application_spec.rb +18 -17
- data/spec/webmachine/chunked_body_spec.rb +3 -3
- data/spec/webmachine/configuration_spec.rb +5 -5
- data/spec/webmachine/cookie_spec.rb +13 -13
- data/spec/webmachine/decision/conneg_spec.rb +48 -42
- data/spec/webmachine/decision/falsey_spec.rb +4 -4
- data/spec/webmachine/decision/flow_spec.rb +194 -144
- data/spec/webmachine/decision/fsm_spec.rb +17 -17
- data/spec/webmachine/decision/helpers_spec.rb +20 -20
- data/spec/webmachine/dispatcher/route_spec.rb +73 -27
- data/spec/webmachine/dispatcher_spec.rb +34 -24
- data/spec/webmachine/errors_spec.rb +1 -1
- data/spec/webmachine/etags_spec.rb +19 -19
- data/spec/webmachine/events_spec.rb +6 -6
- data/spec/webmachine/headers_spec.rb +14 -14
- data/spec/webmachine/media_type_spec.rb +36 -36
- data/spec/webmachine/request_spec.rb +33 -33
- data/spec/webmachine/resource/authentication_spec.rb +6 -6
- data/spec/webmachine/response_spec.rb +12 -12
- data/spec/webmachine/trace/fsm_spec.rb +8 -8
- data/spec/webmachine/trace/resource_proxy_spec.rb +9 -9
- data/spec/webmachine/trace/trace_store_spec.rb +5 -5
- data/spec/webmachine/trace_spec.rb +3 -3
- data/webmachine.gemspec +2 -6
- metadata +48 -206
- data/lib/webmachine/adapters/hatetepe.rb +0 -108
- data/lib/webmachine/adapters/mongrel.rb +0 -127
- data/lib/webmachine/dispatcher/not_found_resource.rb +0 -5
- data/lib/webmachine/fiber18.rb +0 -88
- data/spec/webmachine/adapters/hatetepe_spec.rb +0 -60
- data/spec/webmachine/adapters/mongrel_spec.rb +0 -16
data/lib/webmachine/resource.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'webmachine/constants'
|
2
|
+
|
1
3
|
module Webmachine
|
2
4
|
class Resource
|
3
5
|
# These methods are the primary way your {Webmachine::Resource}
|
@@ -123,7 +125,7 @@ module Webmachine
|
|
123
125
|
# @return [Array<String>] allowed methods on this resource
|
124
126
|
# @api callback
|
125
127
|
def allowed_methods
|
126
|
-
[
|
128
|
+
[GET_METHOD, HEAD_METHOD]
|
127
129
|
end
|
128
130
|
|
129
131
|
# HTTP methods that are known to the resource. Like
|
@@ -134,7 +136,7 @@ module Webmachine
|
|
134
136
|
# @return [Array<String>] known methods
|
135
137
|
# @api callback
|
136
138
|
def known_methods
|
137
|
-
|
139
|
+
STANDARD_HTTP_METHODS
|
138
140
|
end
|
139
141
|
|
140
142
|
# This method is called when a DELETE request should be enacted,
|
@@ -209,7 +211,7 @@ module Webmachine
|
|
209
211
|
# @return an array of mediatype/handler pairs
|
210
212
|
# @api callback
|
211
213
|
def content_types_provided
|
212
|
-
[[
|
214
|
+
[[TEXT_HTML, :to_html]]
|
213
215
|
end
|
214
216
|
|
215
217
|
# Similarly to content_types_provided, this should return an array
|
@@ -263,7 +265,7 @@ module Webmachine
|
|
263
265
|
# @api callback
|
264
266
|
# @see Encodings
|
265
267
|
def encodings_provided
|
266
|
-
{
|
268
|
+
{IDENTITY => :encode_identity }
|
267
269
|
end
|
268
270
|
|
269
271
|
# If this method is implemented, it should return a list of
|
@@ -0,0 +1 @@
|
|
1
|
+
IO response body
|
@@ -4,32 +4,68 @@ require "net/http"
|
|
4
4
|
shared_examples_for :adapter_lint do
|
5
5
|
attr_accessor :client
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
dispatcher = Webmachine::Dispatcher.new
|
10
|
-
dispatcher.add_route ["test"], Test::Resource
|
7
|
+
let(:address) { "127.0.0.1" }
|
8
|
+
let(:port) { s = TCPServer.new(address, 0); p = s.addr[1]; s.close; p }
|
11
9
|
|
12
|
-
|
13
|
-
|
10
|
+
let(:application) do
|
11
|
+
application = Webmachine::Application.new
|
12
|
+
application.dispatcher.add_route ["test"], Test::Resource
|
14
13
|
|
15
|
-
|
16
|
-
|
14
|
+
application.configure do |c|
|
15
|
+
c.ip = address
|
16
|
+
c.port = port
|
17
|
+
end
|
18
|
+
|
19
|
+
application
|
20
|
+
end
|
17
21
|
|
22
|
+
let(:client) do
|
23
|
+
client = Net::HTTP.new(application.configuration.ip, port)
|
18
24
|
# Wait until the server is responsive
|
19
25
|
timeout(5) do
|
20
26
|
begin
|
21
27
|
client.start
|
22
28
|
rescue Errno::ECONNREFUSED
|
23
|
-
sleep(0.
|
29
|
+
sleep(0.01)
|
24
30
|
retry
|
25
31
|
end
|
26
32
|
end
|
33
|
+
client
|
34
|
+
end
|
35
|
+
|
36
|
+
before do
|
37
|
+
@adapter = described_class.new(application)
|
38
|
+
|
39
|
+
Thread.abort_on_exception = true
|
40
|
+
@server_thread = Thread.new { @adapter.run }
|
41
|
+
sleep(0.01)
|
27
42
|
end
|
28
43
|
|
29
|
-
after
|
30
|
-
|
31
|
-
@
|
32
|
-
|
44
|
+
after do
|
45
|
+
client.finish
|
46
|
+
@server_thread.kill
|
47
|
+
end
|
48
|
+
|
49
|
+
it "provides the request URI" do
|
50
|
+
request = Net::HTTP::Get.new("/test")
|
51
|
+
request["Accept"] = "test/response.request_uri"
|
52
|
+
response = client.request(request)
|
53
|
+
expect(response.body).to eq("http://#{address}:#{port}/test")
|
54
|
+
end
|
55
|
+
|
56
|
+
context do
|
57
|
+
let(:address) { "::1" }
|
58
|
+
|
59
|
+
it "provides the IPv6 request URI" do
|
60
|
+
if RUBY_VERSION =~ /^2\.(0|1)\./
|
61
|
+
skip "Net::HTTP regression in Ruby 2.(0|1)"
|
62
|
+
end
|
63
|
+
|
64
|
+
request = Net::HTTP::Get.new("/test")
|
65
|
+
request["Accept"] = "test/response.request_uri"
|
66
|
+
response = client.request(request)
|
67
|
+
expect(response.body).to eq("http://[#{address}]:#{port}/test")
|
68
|
+
end
|
33
69
|
end
|
34
70
|
|
35
71
|
it "provides a string-like request body" do
|
@@ -37,8 +73,8 @@ shared_examples_for :adapter_lint do
|
|
37
73
|
request.body = "Hello, World!"
|
38
74
|
request["Content-Type"] = "test/request.stringbody"
|
39
75
|
response = client.request(request)
|
40
|
-
response["Content-Length"].
|
41
|
-
response.body.
|
76
|
+
expect(response["Content-Length"]).to eq("21")
|
77
|
+
expect(response.body).to eq("String: Hello, World!")
|
42
78
|
end
|
43
79
|
|
44
80
|
it "provides an enumerable request body" do
|
@@ -46,66 +82,64 @@ shared_examples_for :adapter_lint do
|
|
46
82
|
request.body = "Hello, World!"
|
47
83
|
request["Content-Type"] = "test/request.enumbody"
|
48
84
|
response = client.request(request)
|
49
|
-
response["Content-Length"].
|
50
|
-
response.body.
|
85
|
+
expect(response["Content-Length"]).to eq("19")
|
86
|
+
expect(response.body).to eq("Enum: Hello, World!")
|
51
87
|
end
|
52
88
|
|
53
89
|
it "handles missing pages" do
|
54
90
|
request = Net::HTTP::Get.new("/missing")
|
55
91
|
response = client.request(request)
|
56
|
-
response.code.
|
57
|
-
response["Content-Type"].
|
92
|
+
expect(response.code).to eq("404")
|
93
|
+
expect(response["Content-Type"]).to eq("text/html")
|
58
94
|
end
|
59
95
|
|
60
96
|
it "handles empty response bodies" do
|
61
97
|
request = Net::HTTP::Post.new("/test")
|
62
98
|
request.body = ""
|
63
99
|
response = client.request(request)
|
64
|
-
response.code.
|
65
|
-
|
66
|
-
|
67
|
-
response["Content-Length"].should be_nil
|
68
|
-
response.body.should be_nil
|
100
|
+
expect(response.code).to eq("204")
|
101
|
+
expect(["0", nil]).to include(response["Content-Length"])
|
102
|
+
expect(response.body).to be_nil
|
69
103
|
end
|
70
104
|
|
71
105
|
it "handles string response bodies" do
|
72
106
|
request = Net::HTTP::Get.new("/test")
|
73
107
|
request["Accept"] = "test/response.stringbody"
|
74
108
|
response = client.request(request)
|
75
|
-
response["Content-Length"].
|
76
|
-
response.body.
|
109
|
+
expect(response["Content-Length"]).to eq("20")
|
110
|
+
expect(response.body).to eq("String response body")
|
77
111
|
end
|
78
112
|
|
79
113
|
it "handles enumerable response bodies" do
|
80
114
|
request = Net::HTTP::Get.new("/test")
|
81
115
|
request["Accept"] = "test/response.enumbody"
|
82
116
|
response = client.request(request)
|
83
|
-
response["Transfer-Encoding"].
|
84
|
-
response.body.
|
117
|
+
expect(response["Transfer-Encoding"]).to eq("chunked")
|
118
|
+
expect(response.body).to eq("Enumerable response body")
|
85
119
|
end
|
86
120
|
|
87
121
|
it "handles proc response bodies" do
|
88
122
|
request = Net::HTTP::Get.new("/test")
|
89
123
|
request["Accept"] = "test/response.procbody"
|
90
124
|
response = client.request(request)
|
91
|
-
response["Transfer-Encoding"].
|
92
|
-
response.body.
|
125
|
+
expect(response["Transfer-Encoding"]).to eq("chunked")
|
126
|
+
expect(response.body).to eq("Proc response body")
|
93
127
|
end
|
94
128
|
|
95
129
|
it "handles fiber response bodies" do
|
96
130
|
request = Net::HTTP::Get.new("/test")
|
97
131
|
request["Accept"] = "test/response.fiberbody"
|
98
132
|
response = client.request(request)
|
99
|
-
response["Transfer-Encoding"].
|
100
|
-
response.body.
|
133
|
+
expect(response["Transfer-Encoding"]).to eq("chunked")
|
134
|
+
expect(response.body).to eq("Fiber response body")
|
101
135
|
end
|
102
136
|
|
103
137
|
it "handles io response bodies" do
|
104
138
|
request = Net::HTTP::Get.new("/test")
|
105
139
|
request["Accept"] = "test/response.iobody"
|
106
140
|
response = client.request(request)
|
107
|
-
response["Content-Length"].
|
108
|
-
response.body.
|
141
|
+
expect(response["Content-Length"]).to eq("17")
|
142
|
+
expect(response.body).to eq("IO response body\n")
|
109
143
|
end
|
110
144
|
|
111
145
|
it "handles request cookies" do
|
@@ -113,13 +147,13 @@ shared_examples_for :adapter_lint do
|
|
113
147
|
request["Accept"] = "test/response.cookies"
|
114
148
|
request["Cookie"] = "echo=echocookie"
|
115
149
|
response = client.request(request)
|
116
|
-
response.body.
|
150
|
+
expect(response.body).to eq("echocookie")
|
117
151
|
end
|
118
152
|
|
119
153
|
it "handles response cookies" do
|
120
154
|
request = Net::HTTP::Get.new("/test")
|
121
155
|
request["Accept"] = "test/response.cookies"
|
122
156
|
response = client.request(request)
|
123
|
-
response["Set-Cookie"].
|
157
|
+
expect(response["Set-Cookie"]).to eq("cookie=monster, rodeo=clown")
|
124
158
|
end
|
125
159
|
end
|
@@ -17,8 +17,9 @@ module Test
|
|
17
17
|
["test/response.enumbody", :to_enum],
|
18
18
|
["test/response.procbody", :to_proc],
|
19
19
|
["test/response.fiberbody", :to_fiber],
|
20
|
-
["test/response.iobody", :
|
21
|
-
["test/response.cookies", :to_cookies]
|
20
|
+
["test/response.iobody", :to_io_body],
|
21
|
+
["test/response.cookies", :to_cookies],
|
22
|
+
["test/response.request_uri", :to_request_uri]
|
22
23
|
]
|
23
24
|
end
|
24
25
|
|
@@ -58,8 +59,8 @@ module Test
|
|
58
59
|
end
|
59
60
|
end
|
60
61
|
|
61
|
-
def
|
62
|
-
|
62
|
+
def to_io_body
|
63
|
+
File.new(File.expand_path('../IO_response.body', __FILE__))
|
63
64
|
end
|
64
65
|
|
65
66
|
def to_cookies
|
@@ -67,7 +68,12 @@ module Test
|
|
67
68
|
response.set_cookie("rodeo", "clown")
|
68
69
|
# FIXME: Mongrel/WEBrick fail if this method returns nil
|
69
70
|
# Might be a net/http issue. Is this a bug?
|
71
|
+
# @see Flow#o18, Helpers#encode_body_if_set
|
70
72
|
request.cookies["echo"] || ""
|
71
73
|
end
|
74
|
+
|
75
|
+
def to_request_uri
|
76
|
+
request.uri.to_s
|
77
|
+
end
|
72
78
|
end
|
73
79
|
end
|
@@ -30,6 +30,12 @@ module Webmachine
|
|
30
30
|
end
|
31
31
|
end
|
32
32
|
|
33
|
+
# Allows the response body to be converted to a IO object.
|
34
|
+
# @return [IO,nil] the body as a IO object, or nil.
|
35
|
+
def to_io
|
36
|
+
IO.try_convert(body)
|
37
|
+
end
|
38
|
+
|
33
39
|
# Returns the length of the IO stream, if known. Returns nil if
|
34
40
|
# the stream uses an encoder or charsetter that might modify the
|
35
41
|
# length of the stream, or the stream size is unknown.
|
data/lib/webmachine/trace.rb
CHANGED
data/lib/webmachine/trace/fsm.rb
CHANGED
@@ -4,6 +4,22 @@ module Webmachine
|
|
4
4
|
# tracing is enabled for a resource, enabling the capturing of
|
5
5
|
# traces.
|
6
6
|
module FSM
|
7
|
+
# Overrides the default resource accessor so that incoming
|
8
|
+
# callbacks are traced.
|
9
|
+
def initialize(_resource, _request, _response)
|
10
|
+
if trace?
|
11
|
+
class << self
|
12
|
+
def resource
|
13
|
+
@resource_proxy ||= ResourceProxy.new(@resource)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def trace?
|
20
|
+
Trace.trace?(@resource)
|
21
|
+
end
|
22
|
+
|
7
23
|
# Adds the request to the trace.
|
8
24
|
# @param [Webmachine::Request] request the request to be traced
|
9
25
|
def trace_request(request)
|
@@ -13,7 +29,7 @@ module Webmachine
|
|
13
29
|
:path => request.uri.request_uri.to_s,
|
14
30
|
:headers => request.headers,
|
15
31
|
:body => request.body.to_s
|
16
|
-
}
|
32
|
+
} if trace?
|
17
33
|
end
|
18
34
|
|
19
35
|
# Adds the response to the trace and then commits the trace to
|
@@ -25,24 +41,18 @@ module Webmachine
|
|
25
41
|
:code => response.code.to_s,
|
26
42
|
:headers => response.headers,
|
27
43
|
:body => trace_response_body(response.body)
|
28
|
-
}
|
44
|
+
} if trace?
|
29
45
|
ensure
|
30
46
|
Webmachine::Events.publish('wm.trace.record', {
|
31
47
|
:trace_id => resource.object_id.to_s,
|
32
48
|
:trace => response.trace
|
33
|
-
})
|
49
|
+
}) if trace?
|
34
50
|
end
|
35
51
|
|
36
52
|
# Adds a decision to the trace.
|
37
53
|
# @param [Symbol] decision the decision being processed
|
38
54
|
def trace_decision(decision)
|
39
|
-
response.trace << {:type => :decision, :decision => decision}
|
40
|
-
end
|
41
|
-
|
42
|
-
# Overrides the default resource accessor so that incoming
|
43
|
-
# callbacks are traced.
|
44
|
-
def resource
|
45
|
-
@resource_proxy ||= ResourceProxy.new(@resource)
|
55
|
+
response.trace << {:type => :decision, :decision => decision} if trace?
|
46
56
|
end
|
47
57
|
|
48
58
|
private
|
data/lib/webmachine/version.rb
CHANGED
@@ -1,8 +1,8 @@
|
|
1
|
-
module Webmachine
|
1
|
+
module Webmachine
|
2
2
|
# Library version
|
3
|
-
VERSION = "1.
|
3
|
+
VERSION = "1.3.0".freeze
|
4
4
|
|
5
5
|
# String for use in "Server" HTTP response header, which includes
|
6
6
|
# the {VERSION}.
|
7
|
-
SERVER_STRING = "Webmachine-Ruby/#{VERSION}"
|
7
|
+
SERVER_STRING = "Webmachine-Ruby/#{VERSION}".freeze
|
8
8
|
end
|
data/memory_test.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
$:.push File.expand_path("../lib", __FILE__)
|
2
|
+
require 'webmachine'
|
3
|
+
|
4
|
+
class Constantized < Webmachine::Resource
|
5
|
+
HELLO_WORLD = "Hello World".freeze
|
6
|
+
ALLOWED_METHODS = ['GET'.freeze].freeze
|
7
|
+
CONTENT_TYPES_PROVIDED = [['text/html'.freeze, :to_html].freeze].freeze
|
8
|
+
|
9
|
+
def allowed_methods
|
10
|
+
ALLOWED_METHODS
|
11
|
+
end
|
12
|
+
|
13
|
+
def content_types_provided
|
14
|
+
CONTENT_TYPES_PROVIDED
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_html
|
18
|
+
HELLO_WORLD
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
Webmachine.application.routes do
|
23
|
+
add ['constantized'], Constantized
|
24
|
+
end
|
25
|
+
|
26
|
+
require 'webmachine/test'
|
27
|
+
session = Webmachine::Test::Session.new(Webmachine.application)
|
28
|
+
CONSTANTIZED = '/constantized'.freeze
|
29
|
+
require 'memory_profiler'
|
30
|
+
report = MemoryProfiler.report do
|
31
|
+
100.times do
|
32
|
+
session.get(CONSTANTIZED)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
report.pretty_print
|
37
|
+
|
data/spec/spec_helper.rb
CHANGED
@@ -1,16 +1,16 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
require 'rubygems'
|
5
|
-
require 'webmachine'
|
6
|
-
require 'rspec'
|
1
|
+
require "bundler/setup"
|
2
|
+
Bundler.require :default, :test, :webservers
|
7
3
|
require 'logger'
|
8
4
|
|
5
|
+
class NullLogger < Logger
|
6
|
+
def add(severity, message=nil, progname=nil, &block)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
9
10
|
RSpec.configure do |config|
|
10
11
|
config.mock_with :rspec
|
11
12
|
config.filter_run :focus => true
|
12
13
|
config.run_all_when_everything_filtered = true
|
13
|
-
config.treat_symbols_as_metadata_keys_with_true_values = true
|
14
14
|
config.formatter = :documentation if ENV['CI']
|
15
15
|
if defined?(::Java)
|
16
16
|
config.seed = Time.now.utc
|
@@ -20,11 +20,11 @@ RSpec.configure do |config|
|
|
20
20
|
|
21
21
|
config.before(:suite) do
|
22
22
|
options = {
|
23
|
-
:Logger =>
|
23
|
+
:Logger => NullLogger.new(STDERR),
|
24
24
|
:AccessLog => []
|
25
25
|
}
|
26
26
|
Webmachine::Adapters::WEBrick::DEFAULT_OPTIONS.merge! options
|
27
|
-
Webmachine::Adapters::Rack::DEFAULT_OPTIONS.merge! options
|
27
|
+
Webmachine::Adapters::Rack::DEFAULT_OPTIONS.merge! options if defined?(Webmachine::Adapters::Rack)
|
28
28
|
end
|
29
29
|
end
|
30
30
|
|