kaiwren-wrest 0.0.4
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.
- data/README.rdoc +104 -0
- data/Rakefile +203 -0
- data/VERSION.yml +4 -0
- data/bin/wrest +22 -0
- data/lib/wrest.rb +41 -0
- data/lib/wrest/core_ext/string.rb +5 -0
- data/lib/wrest/core_ext/string/conversions.rb +23 -0
- data/lib/wrest/exceptions.rb +1 -0
- data/lib/wrest/exceptions/unsupported_content_type_exception.rb +15 -0
- data/lib/wrest/mappers.rb +21 -0
- data/lib/wrest/mappers/attributes_container.rb +123 -0
- data/lib/wrest/mappers/resource.rb +17 -0
- data/lib/wrest/mappers/resource/base.rb +69 -0
- data/lib/wrest/mappers/resource/collection.rb +12 -0
- data/lib/wrest/mappers/simple_resource.rb +17 -0
- data/lib/wrest/response.rb +38 -0
- data/lib/wrest/translators.rb +26 -0
- data/lib/wrest/translators/content_types.rb +20 -0
- data/lib/wrest/translators/json.rb +21 -0
- data/lib/wrest/translators/typed_hash.rb +4 -0
- data/lib/wrest/translators/xml.rb +24 -0
- data/lib/wrest/uri.rb +74 -0
- data/lib/wrest/uri_template.rb +32 -0
- data/lib/wrest/version.rb +22 -0
- data/spec/custom_matchers/custom_matchers.rb +2 -0
- data/spec/rcov.opts +4 -0
- data/spec/spec.opts +6 -0
- data/spec/spec_helper.rb +18 -0
- data/spec/wrest/mappers/attributes_container_spec.rb +184 -0
- data/spec/wrest/mappers/resource/base_spec.rb +158 -0
- data/spec/wrest/mappers/simple_resource_spec.rb +7 -0
- data/spec/wrest/response_spec.rb +21 -0
- data/spec/wrest/translators/typed_hash_spec.rb +9 -0
- data/spec/wrest/translators/xml_spec.rb +12 -0
- data/spec/wrest/translators_spec.rb +9 -0
- data/spec/wrest/uri_spec.rb +131 -0
- data/spec/wrest/uri_template_spec.rb +28 -0
- metadata +128 -0
@@ -0,0 +1,32 @@
|
|
1
|
+
# Copyright 2009 Sidu Ponnappa
|
2
|
+
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
6
|
+
# Unless required by applicable law or agreed to in writing, software distributed under the License
|
7
|
+
# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
8
|
+
# See the License for the specific language governing permissions and limitations under the License.
|
9
|
+
|
10
|
+
module Wrest
|
11
|
+
class UriTemplate
|
12
|
+
attr_reader :uri_pattern
|
13
|
+
def initialize(uri_pattern)
|
14
|
+
@uri_pattern = uri_pattern.clone
|
15
|
+
end
|
16
|
+
|
17
|
+
# Builds a new Wrest::Uri from this uri template
|
18
|
+
# by replacing the keys in the options that match with
|
19
|
+
# the corressponding values.
|
20
|
+
#
|
21
|
+
# Example:
|
22
|
+
# template = UriTemplate.new("http://localhost:3000/:resource/:id.:format")
|
23
|
+
# template.to_uri(:resource => 'shen_coins', :id => 5, :format => :json)
|
24
|
+
# => #<Wrest::Uri:0x1225514 @uri=#<URI::HTTP:0x9127d8 URL:http://localhost:3000/shen_coins/5.json>>
|
25
|
+
def to_uri(options = {})
|
26
|
+
options.inject(uri_pattern.clone) do |uri_string, tuple|
|
27
|
+
key, value = tuple
|
28
|
+
uri_string.gsub(":#{key.to_s}", value.to_s)
|
29
|
+
end.to_uri
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# Copyright 2009 Sidu Ponnappa
|
2
|
+
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
6
|
+
# Unless required by applicable law or agreed to in writing, software distributed under the License
|
7
|
+
# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
8
|
+
# See the License for the specific language governing permissions and limitations under the License.
|
9
|
+
|
10
|
+
module Wrest
|
11
|
+
module VERSION
|
12
|
+
unless defined? MAJOR
|
13
|
+
MAJOR = 0
|
14
|
+
MINOR = 0
|
15
|
+
TINY = 4
|
16
|
+
|
17
|
+
STRING = [MAJOR, MINOR, TINY].join('.')
|
18
|
+
|
19
|
+
SUMMARY = "wrest version #{STRING}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/spec/rcov.opts
ADDED
data/spec/spec.opts
ADDED
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + "/../lib/wrest")
|
2
|
+
require 'spec'
|
3
|
+
|
4
|
+
["/custom_matchers/**/*.rb"].each{|directory|
|
5
|
+
Dir["#{File.expand_path(File.dirname(__FILE__) + directory)}"].each { |file|
|
6
|
+
require file
|
7
|
+
}
|
8
|
+
}
|
9
|
+
|
10
|
+
Wrest.logger = Logger.new(File.open("#{WREST_ROOT}/../log/test.log", 'a'))
|
11
|
+
|
12
|
+
def p(*args)
|
13
|
+
super *(args << caller[0])
|
14
|
+
end
|
15
|
+
|
16
|
+
Spec::Runner.configure do |config|
|
17
|
+
config.include(CustomMatchers)
|
18
|
+
end
|
@@ -0,0 +1,184 @@
|
|
1
|
+
# Copyright 2009 Sidu Ponnappa
|
2
|
+
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
6
|
+
# Unless required by applicable law or agreed to in writing, software distributed under the License
|
7
|
+
# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
8
|
+
# See the License for the specific language governing permissions and limitations under the License.
|
9
|
+
|
10
|
+
require File.dirname(__FILE__) + '/../../spec_helper'
|
11
|
+
|
12
|
+
module Wrest::Mappers
|
13
|
+
describe AttributesContainer do
|
14
|
+
class HumanBeing
|
15
|
+
include AttributesContainer
|
16
|
+
has_attributes :id
|
17
|
+
end
|
18
|
+
|
19
|
+
it "should allow instantiation with no attributes" do
|
20
|
+
lambda{ HumanBeing.new }.should_not raise_error
|
21
|
+
end
|
22
|
+
|
23
|
+
describe 'has_attributes' do
|
24
|
+
describe 'method creation' do
|
25
|
+
before :each do
|
26
|
+
@Demon = Class.new
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should define attribute getters at the class level" do
|
30
|
+
kai_wren = @Demon.new
|
31
|
+
kai_wren.methods.should_not include('trainer')
|
32
|
+
|
33
|
+
@Demon.class_eval{
|
34
|
+
include AttributesContainer
|
35
|
+
has_attributes :trainer
|
36
|
+
}
|
37
|
+
|
38
|
+
kai_wren.methods.should include('trainer')
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should define attribute setters at the class level" do
|
42
|
+
kai_wren = @Demon.new
|
43
|
+
kai_wren.methods.should_not include('trainer=')
|
44
|
+
|
45
|
+
@Demon.class_eval{
|
46
|
+
include AttributesContainer
|
47
|
+
has_attributes :trainer
|
48
|
+
}
|
49
|
+
|
50
|
+
kai_wren.methods.should include('trainer=')
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should define attribute query methods at the class level" do
|
54
|
+
kai_wren = @Demon.new
|
55
|
+
kai_wren.methods.should_not include('trainer?')
|
56
|
+
|
57
|
+
@Demon.class_eval{
|
58
|
+
include AttributesContainer
|
59
|
+
has_attributes :trainer
|
60
|
+
}
|
61
|
+
kai_wren.methods.should include('trainer?')
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
describe 'method functionality' do
|
66
|
+
before :each do
|
67
|
+
@Demon = Class.new
|
68
|
+
@Demon.class_eval{
|
69
|
+
include AttributesContainer
|
70
|
+
has_attributes :trainer
|
71
|
+
|
72
|
+
def method_missing(method_name, *args)
|
73
|
+
# Ensuring that the instance level
|
74
|
+
# attribute methods don't kick in
|
75
|
+
# by overriding method_missing
|
76
|
+
raise NoMethodError.new("Method #{method_name} was invoked, but doesn't exist", method_name)
|
77
|
+
end
|
78
|
+
}
|
79
|
+
@kai_wren = @Demon.new
|
80
|
+
end
|
81
|
+
|
82
|
+
it "should define attribute getters at the class level" do
|
83
|
+
@kai_wren.instance_variable_get("@attributes")[:trainer] = 'Viss'
|
84
|
+
@kai_wren.trainer.should == 'Viss'
|
85
|
+
end
|
86
|
+
|
87
|
+
it "should define attribute setters at the class level" do
|
88
|
+
@kai_wren.trainer = 'Viss'
|
89
|
+
@kai_wren.instance_variable_get("@attributes")[:trainer].should == 'Viss'
|
90
|
+
end
|
91
|
+
|
92
|
+
it "should define attribute query methods at the class level" do
|
93
|
+
@kai_wren.trainer?.should be_false
|
94
|
+
@kai_wren.instance_variable_get("@attributes")[:trainer] = 'Viss'
|
95
|
+
@kai_wren.trainer?.should be_true
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
describe 'provides an attributes interface which' do
|
101
|
+
before :each do
|
102
|
+
@li_piao = HumanBeing.new(:id => 5, :profession => 'Natural Magician', 'enhanced_by' => 'Kai Wren')
|
103
|
+
end
|
104
|
+
|
105
|
+
it "should provide a generic key based getter that requires symbols" do
|
106
|
+
@li_piao[:profession].should == "Natural Magician"
|
107
|
+
@li_piao['profession'].should == "Natural Magician"
|
108
|
+
end
|
109
|
+
|
110
|
+
it "should provide a generic key based getter that understands symbols" do
|
111
|
+
@li_piao[:enhanced_by] = "Viss"
|
112
|
+
@li_piao.instance_variable_get('@attributes')[:enhanced_by].should == "Viss"
|
113
|
+
end
|
114
|
+
|
115
|
+
it "should provide a generic key based getter that translates strings to symbols" do
|
116
|
+
@li_piao['enhanced_by'] = "Viss"
|
117
|
+
@li_piao.instance_variable_get('@attributes')[:enhanced_by].should == "Viss"
|
118
|
+
end
|
119
|
+
|
120
|
+
it "should fail when getter methods for attributes that don't exist are invoked" do
|
121
|
+
lambda{ @li_piao.ooga }.should raise_error(NoMethodError)
|
122
|
+
end
|
123
|
+
|
124
|
+
it "should provide getter methods for attributes" do
|
125
|
+
@li_piao.profession.should == 'Natural Magician'
|
126
|
+
@li_piao.enhanced_by.should == 'Kai Wren'
|
127
|
+
end
|
128
|
+
|
129
|
+
it "should respond to getter methods for attributes" do
|
130
|
+
@li_piao.should respond_to(:profession)
|
131
|
+
@li_piao.should respond_to(:enhanced_by)
|
132
|
+
end
|
133
|
+
|
134
|
+
it "should not respond to getter methods for attributes that don't exist" do
|
135
|
+
@li_piao.should_not respond_to(:gods)
|
136
|
+
end
|
137
|
+
|
138
|
+
it "should create a setter method when one is invoked for attributes that don't exist" do
|
139
|
+
@li_piao.niece = 'Li Plum'
|
140
|
+
@li_piao.instance_variable_get('@attributes')[:niece].should == 'Li Plum'
|
141
|
+
@li_piao.niece.should == 'Li Plum'
|
142
|
+
end
|
143
|
+
|
144
|
+
it "should provide setter methods for attributes" do
|
145
|
+
@li_piao.enhanced_by = 'He of the Towers of Light'
|
146
|
+
@li_piao.instance_variable_get('@attributes')[:enhanced_by].should == 'He of the Towers of Light'
|
147
|
+
end
|
148
|
+
|
149
|
+
it "should respond to setter methods for attributes" do
|
150
|
+
@li_piao.should respond_to(:profession=)
|
151
|
+
@li_piao.should respond_to(:enhanced_by=)
|
152
|
+
end
|
153
|
+
|
154
|
+
it "should not respond to setter methods for attributes that don't exist" do
|
155
|
+
@li_piao.should_not respond_to(:god=)
|
156
|
+
end
|
157
|
+
|
158
|
+
it "should fail when query methods for attributes that don't exist are invoked" do
|
159
|
+
lambda{ @li_piao.ooga? }.should raise_error(NoMethodError)
|
160
|
+
end
|
161
|
+
|
162
|
+
it "should provide query methods for attributes" do
|
163
|
+
li_piao = HumanBeing.new( :profession => 'Natural Magician', :enhanced_by => nil)
|
164
|
+
li_piao.profession?.should be_true
|
165
|
+
li_piao.enhanced_by?.should be_false
|
166
|
+
end
|
167
|
+
|
168
|
+
it "should respond to query methods for attributes" do
|
169
|
+
@li_piao.should respond_to(:profession?)
|
170
|
+
@li_piao.should respond_to(:enhanced_by?)
|
171
|
+
end
|
172
|
+
|
173
|
+
it "should not respond to query methods for attributes that don't exist" do
|
174
|
+
@li_piao.should_not respond_to(:theronic?)
|
175
|
+
end
|
176
|
+
|
177
|
+
it "should override methods which already exist on the container" do
|
178
|
+
@li_piao.id.should == 5
|
179
|
+
@li_piao.id = 6
|
180
|
+
@li_piao.id.should == 6
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../../../spec_helper'
|
2
|
+
|
3
|
+
class Glassware < Wrest::Mappers::Resource::Base
|
4
|
+
set_host "http://localhost:3000"
|
5
|
+
end
|
6
|
+
|
7
|
+
class BottledUniverse < Glassware
|
8
|
+
set_host "http://localhost:3001"
|
9
|
+
end
|
10
|
+
|
11
|
+
module Wrest::Mappers
|
12
|
+
describe Resource::Base do
|
13
|
+
it "should not affect other classes when setting up its macros" do
|
14
|
+
Class.should_not respond_to(:host=)
|
15
|
+
Object.should_not respond_to(:host=)
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should not affect itself when subclasses use its macros" do
|
19
|
+
Resource::Base.should_not respond_to(:host)
|
20
|
+
end
|
21
|
+
|
22
|
+
describe 'subclasses' do
|
23
|
+
before(:each) do
|
24
|
+
@BottledUniverse = Class.new(Resource::Base)
|
25
|
+
@BottledUniverse.class_eval do
|
26
|
+
set_resource_name 'BottledUniverse'
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should know its name as a resource by default" do
|
31
|
+
BottledUniverse.resource_name.should == 'BottledUniverse'
|
32
|
+
end
|
33
|
+
|
34
|
+
it "should allow its name as a resource to be configured for anonymous classes" do
|
35
|
+
@BottledUniverse.resource_name.should == 'BottledUniverse'
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should know how to create an instance using deserialised attributes" do
|
39
|
+
# Json => {"lead_bottle"=>{"name"=>"Wooz", "id"=>1, "universe_id"=>nil}}
|
40
|
+
# Xml => {"lead-bottle"=>[{"name"=>["Wooz"], "universe-id"=>[{"type"=>"integer", "nil"=>"true"}], "id"=>[{"type"=>"integer", "content"=>"1"}]}]}
|
41
|
+
universe = @BottledUniverse.new "name"=>"Wooz", "id"=>1, "universe_id"=>nil, 'owner_id'=>nil
|
42
|
+
universe.name.should == "Wooz"
|
43
|
+
universe.owner_id.should be_nil
|
44
|
+
universe.id.should == 1
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should allow instantiation with no attributes" do
|
48
|
+
lambda{ @BottledUniverse.new }.should_not raise_error
|
49
|
+
end
|
50
|
+
|
51
|
+
it "should have a method to set the host url" do
|
52
|
+
@BottledUniverse.should respond_to(:set_host)
|
53
|
+
end
|
54
|
+
|
55
|
+
it "should have a method to retrive the host url after it is set" do
|
56
|
+
@BottledUniverse.class_eval{ set_host "http://localhost:3000" }
|
57
|
+
@BottledUniverse.should respond_to(:host)
|
58
|
+
end
|
59
|
+
|
60
|
+
it "should know what its site is" do
|
61
|
+
@BottledUniverse.class_eval{ set_host "http://localhost:3000" }
|
62
|
+
@BottledUniverse.host.should == "http://localhost:3000"
|
63
|
+
end
|
64
|
+
|
65
|
+
it "should not use the same string" do
|
66
|
+
url = "http://localhost:3000"
|
67
|
+
@BottledUniverse.class_eval{ set_host url }
|
68
|
+
url.upcase!
|
69
|
+
@BottledUniverse.host.should == "http://localhost:3000"
|
70
|
+
end
|
71
|
+
|
72
|
+
it "should know its resource path" do
|
73
|
+
Glassware.resource_path.should == '/glasswares'
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
describe 'subclasses of sublasses' do
|
78
|
+
it "should configure its host without affecting its superclass" do
|
79
|
+
Glassware.host.should == "http://localhost:3000"
|
80
|
+
BottledUniverse.host.should == "http://localhost:3001"
|
81
|
+
end
|
82
|
+
|
83
|
+
it "should know its resource path when it is a subclass of a subclass" do
|
84
|
+
BottledUniverse.resource_path.should == '/bottled_universes'
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
describe 'attribute interface' do
|
89
|
+
it "should fail when getter methods for attributes that don't exist are invoked" do
|
90
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
91
|
+
lambda{ universe.ooga }.should raise_error(NoMethodError)
|
92
|
+
end
|
93
|
+
|
94
|
+
it "should provide getter methods for attributes" do
|
95
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
96
|
+
universe.owner.should == 'Kai Wren'
|
97
|
+
universe.guardian.should == 'Lung Shan'
|
98
|
+
end
|
99
|
+
|
100
|
+
it "should respond to getter methods for attributes" do
|
101
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
102
|
+
universe.should respond_to(:owner)
|
103
|
+
universe.should respond_to(:guardian)
|
104
|
+
end
|
105
|
+
|
106
|
+
it "should not respond to getter methods for attributes that don't exist" do
|
107
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
108
|
+
universe.should_not respond_to(:theronic)
|
109
|
+
end
|
110
|
+
|
111
|
+
it "should create a setter method when one is invoked for attributes that don't exist" do
|
112
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
113
|
+
universe.fu_dog = 'Shiriki'
|
114
|
+
universe.attributes[:fu_dog].should == 'Shiriki'
|
115
|
+
universe.fu_dog.should == 'Shiriki'
|
116
|
+
end
|
117
|
+
|
118
|
+
it "should provide setter methods for attributes" do
|
119
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
120
|
+
universe.guardian = 'Effervescent Tiger'
|
121
|
+
universe.attributes[:guardian].should == 'Effervescent Tiger'
|
122
|
+
end
|
123
|
+
|
124
|
+
it "should respond to setter methods for attributes" do
|
125
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
126
|
+
universe.should respond_to(:owner=)
|
127
|
+
universe.should respond_to(:guardian=)
|
128
|
+
end
|
129
|
+
|
130
|
+
it "should not respond to setter methods for attributes that don't exist" do
|
131
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
132
|
+
universe.should_not respond_to(:theronic=)
|
133
|
+
end
|
134
|
+
|
135
|
+
it "should fail when query methods for attributes that don't exist are invoked" do
|
136
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
137
|
+
lambda{ universe.ooga? }.should raise_error(NoMethodError)
|
138
|
+
end
|
139
|
+
|
140
|
+
it "should provide query methods for attributes" do
|
141
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => nil)
|
142
|
+
universe.owner?.should be_true
|
143
|
+
universe.guardian?.should be_false
|
144
|
+
end
|
145
|
+
|
146
|
+
it "should respond to query methods for attributes" do
|
147
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
148
|
+
universe.should respond_to(:owner?)
|
149
|
+
universe.should respond_to(:guardian?)
|
150
|
+
end
|
151
|
+
|
152
|
+
it "should not respond to query methods for attributes that don't exist" do
|
153
|
+
universe = Glassware.new(:owner => 'Kai Wren', :guardian => 'Lung Shan')
|
154
|
+
universe.should_not respond_to(:theronic?)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
|
3
|
+
module Wrest
|
4
|
+
describe Response do
|
5
|
+
it "should know how to delegate to a translator" do
|
6
|
+
http_response = mock('response')
|
7
|
+
Translators::Xml.should_receive(:call).with(http_response)
|
8
|
+
Response.new(http_response).deserialise_using(Translators::Xml)
|
9
|
+
end
|
10
|
+
|
11
|
+
it "should know how to load a translator based on content type" do
|
12
|
+
http_response = mock('response')
|
13
|
+
http_response.should_receive(:content_type).and_return('application/xml')
|
14
|
+
|
15
|
+
response = Response.new(http_response)
|
16
|
+
response.should_receive(:deserialise_using).with(Translators::Xml)
|
17
|
+
|
18
|
+
response.deserialise
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|