halcyon 0.4.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/AUTHORS +1 -0
- data/LICENSE +20 -0
- data/README +107 -0
- data/Rakefile +8 -6
- data/bin/halcyon +3 -204
- data/lib/halcyon.rb +55 -42
- data/lib/halcyon/application.rb +247 -0
- data/lib/halcyon/application/router.rb +86 -0
- data/lib/halcyon/client.rb +187 -35
- data/lib/halcyon/client/ssl.rb +38 -0
- data/lib/halcyon/controller.rb +154 -0
- data/lib/halcyon/exceptions.rb +67 -59
- data/lib/halcyon/logging.rb +31 -0
- data/lib/halcyon/logging/analogger.rb +31 -0
- data/lib/halcyon/logging/helpers.rb +37 -0
- data/lib/halcyon/logging/log4r.rb +25 -0
- data/lib/halcyon/logging/logger.rb +20 -0
- data/lib/halcyon/logging/logging.rb +19 -0
- data/lib/halcyon/runner.rb +141 -0
- data/lib/halcyon/runner/commands.rb +141 -0
- data/lib/halcyon/runner/helpers.rb +9 -0
- data/lib/halcyon/runner/helpers/command_helper.rb +71 -0
- data/spec/halcyon/application_spec.rb +70 -0
- data/spec/halcyon/client_spec.rb +63 -0
- data/spec/halcyon/controller_spec.rb +68 -0
- data/spec/halcyon/halcyon_spec.rb +63 -0
- data/spec/halcyon/logging_spec.rb +31 -0
- data/spec/halcyon/router_spec.rb +37 -12
- data/spec/halcyon/runner_spec.rb +54 -0
- data/spec/spec_helper.rb +75 -9
- data/support/generators/halcyon/USAGE +0 -0
- data/support/generators/halcyon/halcyon_generator.rb +52 -0
- data/support/generators/halcyon/templates/README +26 -0
- data/support/generators/halcyon/templates/Rakefile +32 -0
- data/support/generators/halcyon/templates/app/application.rb +43 -0
- data/support/generators/halcyon/templates/config/config.yml +36 -0
- data/support/generators/halcyon/templates/config/init/environment.rb +11 -0
- data/support/generators/halcyon/templates/config/init/hooks.rb +39 -0
- data/support/generators/halcyon/templates/config/init/requires.rb +10 -0
- data/support/generators/halcyon/templates/config/init/routes.rb +50 -0
- data/support/generators/halcyon/templates/lib/client.rb +77 -0
- data/support/generators/halcyon/templates/runner.ru +8 -0
- data/support/generators/halcyon_flat/USAGE +0 -0
- data/support/generators/halcyon_flat/halcyon_flat_generator.rb +52 -0
- data/support/generators/halcyon_flat/templates/README +26 -0
- data/support/generators/halcyon_flat/templates/Rakefile +32 -0
- data/support/generators/halcyon_flat/templates/app.rb +49 -0
- data/support/generators/halcyon_flat/templates/lib/client.rb +17 -0
- data/support/generators/halcyon_flat/templates/runner.ru +8 -0
- metadata +73 -20
- data/lib/halcyon/client/base.rb +0 -261
- data/lib/halcyon/client/exceptions.rb +0 -41
- data/lib/halcyon/client/router.rb +0 -106
- data/lib/halcyon/server.rb +0 -62
- data/lib/halcyon/server/auth/basic.rb +0 -107
- data/lib/halcyon/server/base.rb +0 -774
- data/lib/halcyon/server/exceptions.rb +0 -41
- data/lib/halcyon/server/router.rb +0 -103
- data/spec/halcyon/error_spec.rb +0 -55
- data/spec/halcyon/server_spec.rb +0 -105
@@ -0,0 +1,63 @@
|
|
1
|
+
#--
|
2
|
+
# Start App for Tests
|
3
|
+
# and wait for it to be responsive
|
4
|
+
#++
|
5
|
+
|
6
|
+
fork do
|
7
|
+
dir = Halcyon.root/'support'/'generators'/'halcyon'/'templates'
|
8
|
+
command = "thin start -R runner.ru -p 89981 -c #{dir} > /dev/null 2>&1"
|
9
|
+
STDOUT.close
|
10
|
+
STDERR.close
|
11
|
+
exec command
|
12
|
+
end
|
13
|
+
client = Halcyon::Client.new('http://localhost:89981')
|
14
|
+
begin
|
15
|
+
sleep 1.5
|
16
|
+
client.get('/time')
|
17
|
+
rescue Errno::ECONNREFUSED => e
|
18
|
+
retry
|
19
|
+
end
|
20
|
+
|
21
|
+
#--
|
22
|
+
# Cleanup
|
23
|
+
#++
|
24
|
+
|
25
|
+
at_exit do
|
26
|
+
pids = (`ps auxww | grep support/generators/halcyon/templates`).split("\n").collect{|pid|pid.match(/(\w+)\s+(\w+).+/)[2]}
|
27
|
+
pids.each {|pid| Process.kill(9, pid.to_i) rescue nil }
|
28
|
+
end
|
29
|
+
|
30
|
+
#--
|
31
|
+
# Tests
|
32
|
+
#++
|
33
|
+
|
34
|
+
describe "Halcyon::Client" do
|
35
|
+
|
36
|
+
before do
|
37
|
+
@client = Halcyon::Client.new('http://localhost:89981')
|
38
|
+
end
|
39
|
+
|
40
|
+
it "should perform requests and return the response values" do
|
41
|
+
response = @client.get('/time')[:body]
|
42
|
+
response.length.should > 25
|
43
|
+
response.include?(Time.now.year.to_s).should.be.true?
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should be able to perform get, post, put, and delete requests" do
|
47
|
+
@client.get('/time')[:body].length.should > 25
|
48
|
+
@client.post('/time')[:body].length.should > 20
|
49
|
+
@client.put('/time')[:body].should == "Not Implemented"
|
50
|
+
@client.delete('/time')[:status].should == 501
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should throw exceptions unless an OK response is sent if toggled to" do
|
54
|
+
# default behavior is to not raise exceptions
|
55
|
+
@client.get('/nonexistent/route')[:status].should == 404
|
56
|
+
|
57
|
+
# tell it to raise exceptions
|
58
|
+
@client.raise_exceptions! true
|
59
|
+
should.raise(Halcyon::Exceptions::NotFound) { @client.get('/nonexistent/route') }
|
60
|
+
@client.get('/time')[:status].should == 200
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
describe "Halcyon::Controller" do
|
2
|
+
|
3
|
+
before do
|
4
|
+
@log = ''
|
5
|
+
@logger = Logger.new(StringIO.new(@log))
|
6
|
+
@config = $config.dup
|
7
|
+
@config[:logger] = @logger
|
8
|
+
@config[:app] = 'Specs'
|
9
|
+
Halcyon.config = @config
|
10
|
+
@app = Halcyon::Runner.new
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should provide various shorthand methods for simple responses but take custom response values" do
|
14
|
+
controller = Specs.new(Rack::MockRequest.env_for('/'))
|
15
|
+
|
16
|
+
response = {:status => 200, :body => 'OK'}
|
17
|
+
controller.ok.should == response
|
18
|
+
controller.success.should == response
|
19
|
+
|
20
|
+
controller.ok('').should == {:status => 200, :body => ''}
|
21
|
+
controller.ok(['OK', 'Sure Thang', 'Correcto']).should == {:status => 200, :body => ['OK', 'Sure Thang', 'Correcto']}
|
22
|
+
end
|
23
|
+
|
24
|
+
it "should provide a quick way to find out what method the request was performed using" do
|
25
|
+
%w(GET POST PUT DELETE).each do |m|
|
26
|
+
controller = Specs.new(Rack::MockRequest.env_for('/', :method => m))
|
27
|
+
controller.method.should == m.downcase.to_sym
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
it "should provide convenient access to GET and POST data" do
|
32
|
+
controller = Specs.new(Rack::MockRequest.env_for("/#{rand}?foo=bar"))
|
33
|
+
controller.get[:foo].should == 'bar'
|
34
|
+
|
35
|
+
controller = Specs.new(Rack::MockRequest.env_for("/#{rand}", :method => 'POST', :input => {:foo => 'bar'}.to_params))
|
36
|
+
controller.post[:foo].should == 'bar'
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should parse URI query params correctly" do
|
40
|
+
controller = Specs.new(Rack::MockRequest.env_for("/?query=value&lang=en-US"))
|
41
|
+
controller.get[:query].should == 'value'
|
42
|
+
controller.get[:lang].should == 'en-US'
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should parse the URI correctly" do
|
46
|
+
controller = Specs.new(Rack::MockRequest.env_for("http://localhost:4000/slaughterhouse/5"))
|
47
|
+
controller.uri.should == '/slaughterhouse/5'
|
48
|
+
|
49
|
+
controller = Specs.new(Rack::MockRequest.env_for("/slaughterhouse/5"))
|
50
|
+
controller.uri.should == '/slaughterhouse/5'
|
51
|
+
|
52
|
+
controller = Specs.new(Rack::MockRequest.env_for(""))
|
53
|
+
controller.uri.should == '/'
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'should provide url accessor for resource index route' do
|
57
|
+
controller = Resources.new(Rack::MockRequest.env_for("/resources"))
|
58
|
+
controller.uri.should == controller.url(:resources)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'should provide url accessor for resource show route' do
|
62
|
+
resource = Model.new
|
63
|
+
resource.id = 1
|
64
|
+
controller = Resources.new(Rack::MockRequest.env_for("/resources/1"))
|
65
|
+
controller.uri.should == controller.url(:resource, resource)
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
describe "Halcyon" do
|
2
|
+
|
3
|
+
before do
|
4
|
+
@log = ''
|
5
|
+
@logger = Logger.new(StringIO.new(@log))
|
6
|
+
@config = $config.dup
|
7
|
+
@config[:logger] = @logger
|
8
|
+
@config[:app] = 'Specs'
|
9
|
+
Halcyon.config = @config
|
10
|
+
@app = Halcyon::Runner.new
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should provide the path of the application root directory" do
|
14
|
+
Halcyon.root.should == Dir.pwd
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should provide quick access to the configuration hash" do
|
18
|
+
Halcyon.config.is_a?(Hash).should.be.true?
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should provide environment label" do
|
22
|
+
Halcyon.environment.should == :development
|
23
|
+
Halcyon.environment.should == Halcyon.config[:environment]
|
24
|
+
end
|
25
|
+
|
26
|
+
it "should provide universal access to a logger" do
|
27
|
+
# We assume Logger here because, you know, we're gods of the test
|
28
|
+
Halcyon.logger.is_a?(Logger).should.be.true?
|
29
|
+
# And this is just a side affect of making the logger universally accessible
|
30
|
+
{}.logger.is_a?(Logger).should.be.true?
|
31
|
+
end
|
32
|
+
|
33
|
+
it "should provide the (estimated) application name" do
|
34
|
+
# We set this above
|
35
|
+
Halcyon.app.should == "Specs"
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should provide sane default paths for essential components" do
|
39
|
+
Halcyon.paths.is_a?(Hash).should.be.true?
|
40
|
+
Halcyon.paths[:controller].should == Halcyon.root/"app"
|
41
|
+
Halcyon.paths[:lib].should == Halcyon.root/"lib"
|
42
|
+
Halcyon.paths[:config].should == Halcyon.root/"config"
|
43
|
+
Halcyon.paths[:init].should == Halcyon.root/"config"/"{init,initialize}"
|
44
|
+
Halcyon.paths[:log].should == Halcyon.root/"log"
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should provide configurable attribute definition for quick access to specific configuration values" do
|
48
|
+
test_method = "oracle"
|
49
|
+
method_count = Halcyon.methods.length
|
50
|
+
Halcyon.configurable("oracle")
|
51
|
+
(Halcyon.methods.length - method_count).should == 2
|
52
|
+
Halcyon.method(test_method.to_sym).is_a?(Method).should.be.true?
|
53
|
+
Halcyon.method("#{test_method}=".to_sym).is_a?(Method).should.be.true?
|
54
|
+
Halcyon.send("#{test_method}=".to_sym, 10)
|
55
|
+
Halcyon.send(test_method).should == Halcyon.config[test_method.to_sym]
|
56
|
+
end
|
57
|
+
|
58
|
+
it "should predefine quick access to the 'db' configuration value" do
|
59
|
+
Halcyon.db = 100
|
60
|
+
Halcyon.db.should == Halcyon.config[:db]
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
describe "Halcyon::Logging" do
|
2
|
+
|
3
|
+
it "should set the default logger when none specified" do
|
4
|
+
Halcyon.send(:remove_const, :Logger)
|
5
|
+
Halcyon::Logging.set
|
6
|
+
Halcyon::Logger.ancestors.include?(::Logger).should.be.true?
|
7
|
+
end
|
8
|
+
|
9
|
+
it "should set the logger type specified" do
|
10
|
+
Halcyon.send(:remove_const, :Logger)
|
11
|
+
Halcyon::Logging.set('Logger')
|
12
|
+
Halcyon::Logger.ancestors.include?(::Logger).should.be.true?
|
13
|
+
|
14
|
+
# Not running these because the above test is equivalent as well as
|
15
|
+
# throwing errors for folks who do not have Logging, Log4r, and
|
16
|
+
# Analogger installed.
|
17
|
+
|
18
|
+
# Halcyon.send(:remove_const, :Logger)
|
19
|
+
# Halcyon::Logging.set('Analogger')
|
20
|
+
# Halcyon::Logger.ancestors.include?(::Swiftcore::Analogger::Client).should.be.true?
|
21
|
+
#
|
22
|
+
# Halcyon.send(:remove_const, :Logger)
|
23
|
+
# Halcyon::Logging.set('Log4r')
|
24
|
+
# Halcyon::Logger.ancestors.include?(::Log4r::Logger).should.be.true?
|
25
|
+
#
|
26
|
+
# Halcyon.send(:remove_const, :Logger)
|
27
|
+
# Halcyon::Logging.set('Logging')
|
28
|
+
# Halcyon::Logger.ancestors.include?(::Logging::Logger).should.be.true?
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
data/spec/halcyon/router_spec.rb
CHANGED
@@ -1,32 +1,57 @@
|
|
1
|
-
describe "Halcyon::
|
1
|
+
describe "Halcyon::Application::Router" do
|
2
2
|
|
3
3
|
before do
|
4
|
-
@
|
4
|
+
@log = ''
|
5
|
+
@logger = Logger.new(StringIO.new(@log))
|
6
|
+
@config = $config.dup
|
7
|
+
@config[:logger] = @logger
|
8
|
+
Halcyon.config = @config
|
9
|
+
@app = Halcyon::Runner.new
|
5
10
|
end
|
6
11
|
|
7
|
-
it "should
|
12
|
+
it "should prepare routes correctly when written correctly" do
|
8
13
|
# routes have been defined for Specr
|
9
|
-
Halcyon::
|
10
|
-
Halcyon::
|
14
|
+
Halcyon::Application::Router.routes.should.not == []
|
15
|
+
Halcyon::Application::Router.routes.length.should > 0
|
11
16
|
end
|
12
17
|
|
13
18
|
it "should match URIs to the correct route" do
|
14
|
-
|
19
|
+
request = Rack::Request.new(Rack::MockRequest.env_for('/'))
|
20
|
+
Halcyon::Application::Router.route(request)[:action].should == 'index'
|
15
21
|
end
|
16
22
|
|
17
23
|
it "should use the default route if no matching route is found" do
|
18
|
-
|
19
|
-
|
24
|
+
# missing instead of not_found because we gave a different default route
|
25
|
+
request = Rack::Request.new(Rack::MockRequest.env_for("/erroneous/path/#{rand}/#{rand}"))
|
26
|
+
Halcyon::Application::Router.route(request)[:action].should == 'missing'
|
27
|
+
|
28
|
+
request = Rack::Request.new(Rack::MockRequest.env_for("/random/#{rand}/#{rand}"))
|
29
|
+
Halcyon::Application::Router.route(request)[:action].should == 'missing'
|
20
30
|
end
|
21
31
|
|
22
32
|
it "should map params in routes to parameters" do
|
23
|
-
|
24
|
-
|
25
|
-
|
33
|
+
request = Rack::Request.new(Rack::MockRequest.env_for('/hello/Matt'))
|
34
|
+
response = Halcyon::Application::Router.route(request)
|
35
|
+
response[:action].should == 'greeter'
|
36
|
+
response[:name].should == 'Matt'
|
26
37
|
end
|
27
38
|
|
28
39
|
it "should supply arbitrary routing param values included as a param even if not in the URI" do
|
29
|
-
|
40
|
+
request = Rack::Request.new(Rack::MockRequest.env_for('/'))
|
41
|
+
request.env['rack.input'] << "arbitrary=random"
|
42
|
+
Halcyon::Application::Router.route(request)[:arbitrary].should == 'random'
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should match index method of resources" do
|
46
|
+
index_req = Rack::Request.new(Rack::MockRequest.env_for('/resources'))
|
47
|
+
Halcyon::Application::Router.route(index_req)[:controller].should == 'resources'
|
48
|
+
Halcyon::Application::Router.route(index_req)[:action].should == 'index'
|
49
|
+
end
|
50
|
+
|
51
|
+
it "should match show method of resource" do
|
52
|
+
show_req = Rack::Request.new(Rack::MockRequest.env_for('/resources/id'))
|
53
|
+
Halcyon::Application::Router.route(show_req)[:controller].should == 'resources'
|
54
|
+
Halcyon::Application::Router.route(show_req)[:action].should == 'show'
|
30
55
|
end
|
31
56
|
|
32
57
|
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module Kernel
|
2
|
+
alias_method :__warn, :warn
|
3
|
+
def warn(msg)
|
4
|
+
$warning = msg
|
5
|
+
__warn(msg) if $do_warns
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
describe "Halcyon::Runner" do
|
10
|
+
|
11
|
+
before do
|
12
|
+
@log = ''
|
13
|
+
@logger = Logger.new(StringIO.new(@log))
|
14
|
+
@config = $config.dup
|
15
|
+
@config[:logger] = @logger
|
16
|
+
Halcyon.config = @config
|
17
|
+
@app = Halcyon::Runner.new
|
18
|
+
end
|
19
|
+
|
20
|
+
it "should warn if a non-existent config file is loaded" do
|
21
|
+
$do_warns = false
|
22
|
+
path = Halcyon.root/'config'/'config.yml'
|
23
|
+
Halcyon::Runner.load_config(path).nil?.should == true
|
24
|
+
$warning.should =~ %r{#{path} not found}
|
25
|
+
$do_warns = true
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should set up logging according to configuration" do
|
29
|
+
time = Time.now.to_s
|
30
|
+
@app.logger.debug "Test message for #{time}"
|
31
|
+
@log.should =~ /Test message for #{time}/
|
32
|
+
end
|
33
|
+
|
34
|
+
it "should recognize what application it is running as" do
|
35
|
+
# Without setting explicitly in config
|
36
|
+
Halcyon.app.should == Halcyon.root.split('/').last.camel_case
|
37
|
+
|
38
|
+
# With setting explicitly in config
|
39
|
+
Halcyon.config[:app] = 'Specr'
|
40
|
+
Halcyon::Runner.new
|
41
|
+
Halcyon.app.should == 'Specr'
|
42
|
+
|
43
|
+
# Setting directly
|
44
|
+
Halcyon.app = 'Specr2'
|
45
|
+
Halcyon.app.should == 'Specr2'
|
46
|
+
end
|
47
|
+
|
48
|
+
it "should proxy calls to Halcyon::Application" do
|
49
|
+
status, headers, body = @app.call(Rack::MockRequest.env_for('/'))
|
50
|
+
status.should == 200
|
51
|
+
body.body[0].should == Specs.new(Rack::MockRequest.env_for('/')).send(:index).to_json
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,21 +1,87 @@
|
|
1
|
-
require 'halcyon
|
1
|
+
require File.join(File.dirname(__FILE__), '..', 'lib', 'halcyon')
|
2
2
|
require 'rack/mock'
|
3
|
+
require 'logger'
|
3
4
|
|
4
|
-
|
5
|
+
# Default Settings
|
5
6
|
|
6
|
-
|
7
|
+
$config = {
|
8
|
+
:allow_from => :all,
|
9
|
+
:environment => :development,
|
10
|
+
:logger => nil,
|
11
|
+
:logging => {
|
12
|
+
:level => 'debug'
|
13
|
+
}
|
14
|
+
}
|
15
|
+
|
16
|
+
# Testing Application
|
17
|
+
|
18
|
+
# Default controller
|
19
|
+
class Application < Halcyon::Controller; end
|
20
|
+
|
21
|
+
# Weird edge-case controller
|
22
|
+
class Specs < Application
|
7
23
|
|
8
|
-
|
9
|
-
|
10
|
-
r.match('/').to(:action => 'index', :arbitrary => 'random')
|
24
|
+
def greeter
|
25
|
+
ok("Hello #{params[:name]}")
|
11
26
|
end
|
12
27
|
|
13
|
-
def index
|
28
|
+
def index
|
14
29
|
ok('Found')
|
15
30
|
end
|
16
31
|
|
17
|
-
def
|
18
|
-
|
32
|
+
def cause_exception
|
33
|
+
raise Exception.new("Oops!")
|
34
|
+
end
|
35
|
+
|
36
|
+
def call_nonexistent_method
|
37
|
+
hash = Hash.new
|
38
|
+
hash.please_dont_exist_and_please_throw_no_method_error
|
39
|
+
ok
|
19
40
|
end
|
20
41
|
|
42
|
+
private
|
43
|
+
|
44
|
+
def undispatchable_private_method
|
45
|
+
"it's private, so it won't be found by the dispatcher"
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
# Resources controller
|
51
|
+
class Resources < Application
|
52
|
+
|
53
|
+
def index
|
54
|
+
ok('List of resources')
|
55
|
+
end
|
56
|
+
|
57
|
+
def show
|
58
|
+
ok("One resource: #{params[:id]}")
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Models
|
63
|
+
|
64
|
+
class Model
|
65
|
+
attr_accessor :id
|
66
|
+
end
|
67
|
+
|
68
|
+
# Environment
|
69
|
+
|
70
|
+
Halcyon.configurable_attr(:environment)
|
71
|
+
|
72
|
+
# Testing routes
|
73
|
+
|
74
|
+
Halcyon::Application.route do |r|
|
75
|
+
r.resources :resources
|
76
|
+
|
77
|
+
r.match('/hello/:name').to(:controller => 'specs', :action => 'greeter')
|
78
|
+
r.match('/:action').to(:controller => 'specs')
|
79
|
+
r.match('/:controller/:action').to()
|
80
|
+
r.match('/').to(:controller => 'specs', :action => 'index', :arbitrary => 'random')
|
81
|
+
# r.default_routes
|
82
|
+
{:action => 'missing'}
|
83
|
+
end
|
84
|
+
|
85
|
+
Halcyon::Application.startup do |config|
|
86
|
+
$started = true
|
21
87
|
end
|