dynamodb 0.0.2 → 1.1.1
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/.gitignore +1 -0
- data/Gemfile +2 -1
- data/README.md +41 -11
- data/dynamodb.gemspec +3 -2
- data/lib/dynamodb.rb +18 -17
- data/lib/dynamodb/connection.rb +34 -70
- data/lib/dynamodb/failure_response.rb +41 -0
- data/lib/dynamodb/http_handler.rb +74 -0
- data/lib/dynamodb/request.rb +107 -0
- data/lib/dynamodb/success_response.rb +51 -0
- data/lib/dynamodb/version.rb +3 -0
- data/lib/net/http/connection_pool.rb +226 -0
- data/lib/net/http/connection_pool/connection.rb +189 -0
- data/lib/net/http/connection_pool/session.rb +126 -0
- data/spec/dynamodb/connection_spec.rb +14 -117
- data/spec/dynamodb/failure_response_spec.rb +27 -0
- data/spec/dynamodb/http_handler_spec.rb +71 -0
- data/spec/dynamodb/request_spec.rb +31 -0
- data/spec/dynamodb/success_response_spec.rb +93 -0
- data/spec/dynamodb_spec.rb +24 -0
- metadata +18 -28
- data/lib/dynamodb/credentials.rb +0 -30
- data/lib/dynamodb/response.rb +0 -33
- data/lib/dynamodb/security_token_service.rb +0 -110
- data/lib/dynamodb/typhoeus/request.rb +0 -27
- data/spec/dynamodb/credentials_spec.rb +0 -22
- data/spec/dynamodb/response_spec.rb +0 -87
- data/spec/dynamodb/security_token_service_spec.rb +0 -97
data/.gitignore
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -15,15 +15,33 @@ requests are done through `#post`. The first argument is the name of the
|
|
15
15
|
operation, the second is a hash that will be converted to JSON and used as the
|
16
16
|
request body.
|
17
17
|
|
18
|
+
Responses come back as `SuccessResponse` or `FailureResponse` objects.
|
19
|
+
|
18
20
|
require 'dynamodb'
|
19
21
|
|
20
|
-
conn = DynamoDB::Connection.new 'ACCESS_KEY', 'SECRET_KEY'
|
22
|
+
> conn = DynamoDB::Connection.new(access_key_id: 'ACCESS_KEY', secret_access_key: 'SECRET_KEY')
|
23
|
+
=> #<DynamoDB::Connection:...>
|
24
|
+
|
25
|
+
> response = conn.post(:ListTables)
|
26
|
+
=> #<DynamoDB::SuccessResponse:...>
|
27
|
+
|
28
|
+
> response.data
|
29
|
+
=> {"TableNames"=>["my-dynamo-table"]}
|
30
|
+
|
31
|
+
For operations that return `Item` or `Items` keys, there are friendly accessors
|
32
|
+
on the responses that also cast the values into strings and numbers:
|
21
33
|
|
22
|
-
conn.post :
|
23
|
-
|
34
|
+
> response = conn.post(:GetItem, {:TableName => "my-dynamo-table", :Key => {:S => "some-key"}})
|
35
|
+
=> #<DynamoDB::SuccessResponse:...>
|
24
36
|
|
25
|
-
|
26
|
-
|
37
|
+
> response.item
|
38
|
+
=> {"text"=>"Hey there"}
|
39
|
+
|
40
|
+
> response = conn.post(:Query, {...})
|
41
|
+
=> #<DynamoDB::SuccessResponse:...>
|
42
|
+
|
43
|
+
> response.items
|
44
|
+
=> [{...}]
|
27
45
|
|
28
46
|
TODO
|
29
47
|
----
|
@@ -33,7 +51,7 @@ TODO
|
|
33
51
|
Credits
|
34
52
|
-------
|
35
53
|
|
36
|
-
This project started as a fork of [Jedlik](https://github.com/hashmal/jedlik)
|
54
|
+
This project started as a fork of [Jedlik](https://github.com/hashmal/jedlik)
|
37
55
|
by [Jérémy Pinat](https://github.com/hashmal) but has significantly diverged.
|
38
56
|
|
39
57
|
License
|
@@ -41,8 +59,20 @@ License
|
|
41
59
|
|
42
60
|
Copyright (c) 2011-2012 GroupMe, Inc.
|
43
61
|
|
44
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
62
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
63
|
+
of this software and associated documentation files (the "Software"), to deal
|
64
|
+
in the Software without restriction, including without limitation the rights
|
65
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
66
|
+
copies of the Software, and to permit persons to whom the Software is
|
67
|
+
furnished to do so, subject to the following conditions:
|
68
|
+
|
69
|
+
The above copyright notice and this permission notice shall be included in
|
70
|
+
all copies or substantial portions of the Software.
|
71
|
+
|
72
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
73
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
74
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
75
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
76
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
77
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
78
|
+
SOFTWARE.
|
data/dynamodb.gemspec
CHANGED
@@ -1,6 +1,8 @@
|
|
1
|
+
require "./lib/dynamodb/version"
|
2
|
+
|
1
3
|
Gem::Specification.new do |s|
|
2
4
|
s.name = 'dynamodb'
|
3
|
-
s.version =
|
5
|
+
s.version = DynamoDB::VERSION
|
4
6
|
s.summary = "Communicate with Amazon DynamoDB."
|
5
7
|
s.description = "Communicate with Amazon DynamoDB. Raw access to the full API without having to handle temporary credentials or HTTP requests by yourself."
|
6
8
|
s.authors = ["Brandon Keene", "Dave Yeu"]
|
@@ -11,7 +13,6 @@ Gem::Specification.new do |s|
|
|
11
13
|
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
14
|
s.homepage = 'http://github.com/groupme/dynamodb'
|
13
15
|
|
14
|
-
s.add_runtime_dependency 'typhoeus', '0.4.2'
|
15
16
|
s.add_runtime_dependency 'multi_json', '1.3.7'
|
16
17
|
|
17
18
|
s.add_development_dependency 'rspec', '2.8.0'
|
data/lib/dynamodb.rb
CHANGED
@@ -1,30 +1,22 @@
|
|
1
|
-
require
|
1
|
+
require "uri"
|
2
2
|
require 'time'
|
3
|
-
require 'base64'
|
4
|
-
require 'openssl'
|
5
3
|
require 'cgi'
|
6
4
|
require 'multi_json'
|
7
5
|
|
8
6
|
module DynamoDB
|
9
|
-
class BaseError < RuntimeError
|
10
|
-
attr_reader :response
|
11
|
-
|
12
|
-
def initialize(response)
|
13
|
-
@response = response
|
14
|
-
super("#{response.code}: #{response.body}")
|
15
|
-
end
|
16
|
-
end
|
17
|
-
|
7
|
+
class BaseError < RuntimeError; end
|
18
8
|
class ClientError < BaseError; end
|
19
9
|
class ServerError < BaseError; end
|
20
|
-
class TimeoutError < BaseError; end
|
21
10
|
class AuthenticationError < BaseError; end
|
22
11
|
|
23
|
-
|
24
|
-
|
25
|
-
require 'dynamodb/
|
12
|
+
Credentials = Struct.new(:access_key_id, :secret_access_key)
|
13
|
+
|
14
|
+
require 'dynamodb/version'
|
26
15
|
require 'dynamodb/connection'
|
27
|
-
require 'dynamodb/
|
16
|
+
require 'dynamodb/http_handler'
|
17
|
+
require 'dynamodb/request'
|
18
|
+
require 'dynamodb/success_response'
|
19
|
+
require 'dynamodb/failure_response'
|
28
20
|
|
29
21
|
class << self
|
30
22
|
def serialize(object)
|
@@ -70,6 +62,14 @@ module DynamoDB
|
|
70
62
|
{"N" => (value ? 1 : 0).to_s}
|
71
63
|
when Time
|
72
64
|
{"N" => value.to_f.to_s}
|
65
|
+
when Array
|
66
|
+
if value.all? {|n| n.kind_of?(String) }
|
67
|
+
{"SS" => value.uniq}
|
68
|
+
elsif value.all? {|n| n.kind_of?(Numeric) }
|
69
|
+
{"NS" => value.uniq}
|
70
|
+
else
|
71
|
+
raise ClientError.new("cannot mix data types in sets")
|
72
|
+
end
|
73
73
|
else
|
74
74
|
{"S" => value.to_s}
|
75
75
|
end
|
@@ -81,6 +81,7 @@ module DynamoDB
|
|
81
81
|
case k
|
82
82
|
when "N" then v.include?('.') ? v.to_f : v.to_i
|
83
83
|
when "S" then v.to_s
|
84
|
+
when "SS","NS" then v
|
84
85
|
else
|
85
86
|
raise "Type not recoginized: #{k}"
|
86
87
|
end
|
data/lib/dynamodb/connection.rb
CHANGED
@@ -1,97 +1,61 @@
|
|
1
1
|
module DynamoDB
|
2
2
|
# Establishes a connection to Amazon DynamoDB using credentials.
|
3
3
|
class Connection
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
4
|
+
class << self
|
5
|
+
def http_handler
|
6
|
+
@http_handler ||= HttpHandler.new
|
7
|
+
end
|
8
8
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
9
|
+
def http_handler=(new_http_handler)
|
10
|
+
@http_handler = new_http_handler
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# Create a connection
|
15
|
+
# uri: # default 'https://dynamodb.us-east-1.amazonaws.com/'
|
16
|
+
# timeout: # default 5 seconds
|
17
|
+
# api_version: # default
|
13
18
|
#
|
14
19
|
def initialize(opts = {})
|
15
|
-
opts
|
16
|
-
|
17
|
-
if opts[:token_service]
|
18
|
-
@sts = opts[:token_service]
|
19
|
-
elsif opts[:access_key_id] && opts[:secret_access_key]
|
20
|
-
@sts = SecurityTokenService.new(opts[:access_key_id], opts[:secret_access_key])
|
20
|
+
if opts[:access_key_id] && opts[:secret_access_key]
|
21
|
+
@credentials = DynamoDB::Credentials.new(opts[:access_key_id], opts[:secret_access_key])
|
21
22
|
else
|
22
23
|
raise ArgumentError.new("access_key_id and secret_access_key are required")
|
23
24
|
end
|
24
25
|
|
25
|
-
@
|
26
|
-
|
26
|
+
@uri = URI(opts[:uri] || "https://dynamodb.us-east-1.amazonaws.com/")
|
27
|
+
set_timeout(opts[:timeout]) if opts[:timeout]
|
28
|
+
|
29
|
+
@api_version = opts[:api_version] || "DynamoDB_20111205"
|
27
30
|
end
|
28
31
|
|
29
|
-
# Create and send a request to DynamoDB
|
30
|
-
#
|
32
|
+
# Create and send a request to DynamoDB
|
33
|
+
#
|
34
|
+
# This returns either a SuccessResponse or a FailureResponse.
|
31
35
|
#
|
32
36
|
# `operation` can be any DynamoDB operation. `data` is a hash that will be
|
33
37
|
# used as the request body (in JSON format). More info available at:
|
34
|
-
#
|
35
38
|
# http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide
|
36
39
|
#
|
37
40
|
def post(operation, data={})
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
if response.success?
|
48
|
-
case operation
|
49
|
-
when :Query, :Scan, :GetItem
|
50
|
-
DynamoDB::Response.new(response)
|
51
|
-
else
|
52
|
-
MultiJson.load(response.body)
|
53
|
-
end
|
54
|
-
else
|
55
|
-
raise_error(response)
|
56
|
-
end
|
41
|
+
request = DynamoDB::Request.new(
|
42
|
+
uri: @uri,
|
43
|
+
credentials: @credentials,
|
44
|
+
api_version: @api_version,
|
45
|
+
operation: operation,
|
46
|
+
data: data
|
47
|
+
)
|
48
|
+
http_handler.handle(request)
|
57
49
|
end
|
58
50
|
|
59
51
|
private
|
60
52
|
|
61
|
-
def
|
62
|
-
|
53
|
+
def http_handler
|
54
|
+
self.class.http_handler
|
63
55
|
end
|
64
56
|
|
65
|
-
def
|
66
|
-
|
67
|
-
:method => :post,
|
68
|
-
:body => body,
|
69
|
-
:timeout => @timeout,
|
70
|
-
:connect_timeout => @timeout,
|
71
|
-
:headers => {
|
72
|
-
'host' => @endpoint,
|
73
|
-
'content-type' => "application/x-amz-json-1.0",
|
74
|
-
'x-amz-date' => (Time.now.utc.strftime "%a, %d %b %Y %H:%M:%S GMT"),
|
75
|
-
'x-amz-security-token' => credentials.session_token,
|
76
|
-
'x-amz-target' => "DynamoDB_20111205.#{operation}",
|
77
|
-
}
|
78
|
-
end
|
79
|
-
|
80
|
-
def raise_error(response)
|
81
|
-
if response.timed_out?
|
82
|
-
raise TimeoutError.new(response)
|
83
|
-
else
|
84
|
-
case response.code
|
85
|
-
when 400..499
|
86
|
-
raise ClientError.new(response)
|
87
|
-
when 500..599
|
88
|
-
raise ServerError.new(response)
|
89
|
-
when 0
|
90
|
-
raise ServerError.new(response)
|
91
|
-
else
|
92
|
-
raise BaseError.new(response)
|
93
|
-
end
|
94
|
-
end
|
57
|
+
def set_timeout(timeout)
|
58
|
+
http_handler.timeout = timeout
|
95
59
|
end
|
96
60
|
end
|
97
61
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module DynamoDB
|
2
|
+
# Failed response from Dynamo
|
3
|
+
#
|
4
|
+
# The #error can be:
|
5
|
+
# * `ClientError` for 4XX responses
|
6
|
+
# * `ServerError` for 5XX or unknown responses
|
7
|
+
# * Network errors, which are enumerated in HttpHandler
|
8
|
+
class FailureResponse
|
9
|
+
attr_accessor :error, :body, :code
|
10
|
+
|
11
|
+
def initialize(http_response = nil)
|
12
|
+
@http_response = http_response
|
13
|
+
end
|
14
|
+
|
15
|
+
def success?
|
16
|
+
false
|
17
|
+
end
|
18
|
+
|
19
|
+
def error
|
20
|
+
@error ||= http_response_error
|
21
|
+
end
|
22
|
+
|
23
|
+
def body
|
24
|
+
@body ||= @http_response && @http_response.body
|
25
|
+
end
|
26
|
+
|
27
|
+
def code
|
28
|
+
@code ||= @http_response && @http_response.code
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def http_response_error
|
34
|
+
if (400..499).include?(code.to_i)
|
35
|
+
ClientError.new("#{code}: #{@http_response.message}")
|
36
|
+
else
|
37
|
+
ServerError.new("#{code}: #{@http_response.message}")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require "net/http/connection_pool"
|
2
|
+
|
3
|
+
module DynamoDB
|
4
|
+
# Process HTTP requests
|
5
|
+
#
|
6
|
+
# Kudos to AWS's NetHttpHandler class for the inspiration here.
|
7
|
+
# Re-using a single instance of this class is recommended, since
|
8
|
+
# it relies upon persistent HTTP connections managed by a pool.
|
9
|
+
class HttpHandler
|
10
|
+
DEFAULT_TIMEOUT = 5 # seconds
|
11
|
+
|
12
|
+
NETWORK_ERRORS = [
|
13
|
+
SocketError,
|
14
|
+
EOFError,
|
15
|
+
IOError,
|
16
|
+
Errno::ECONNABORTED,
|
17
|
+
Errno::ECONNRESET,
|
18
|
+
Errno::EPIPE,
|
19
|
+
Errno::EINVAL,
|
20
|
+
Timeout::Error,
|
21
|
+
Errno::ETIMEDOUT
|
22
|
+
]
|
23
|
+
|
24
|
+
attr_writer :timeout
|
25
|
+
|
26
|
+
def initialize(options = {})
|
27
|
+
@pool = Net::HTTP::ConnectionPool.new
|
28
|
+
@timeout = options[:timeout] || DEFAULT_TIMEOUT
|
29
|
+
end
|
30
|
+
|
31
|
+
# Perform an HTTP request
|
32
|
+
#
|
33
|
+
# The argument should be a `DynamoDB::Request` object, and the
|
34
|
+
# return value is either a `DynamoDB::SuccessResponse` or a
|
35
|
+
# `DynamoDB::FailureResponse`.
|
36
|
+
def handle(request)
|
37
|
+
connection = @pool.connection_for(request.uri.host, {
|
38
|
+
port: request.uri.port,
|
39
|
+
ssl: request.uri.scheme == "https",
|
40
|
+
ssl_verify_peer: true
|
41
|
+
})
|
42
|
+
connection.read_timeout = @timeout
|
43
|
+
|
44
|
+
begin
|
45
|
+
response = nil
|
46
|
+
connection.request(build_http_request(request)) do |http_response|
|
47
|
+
if http_response.code.to_i < 300
|
48
|
+
response = SuccessResponse.new(http_response)
|
49
|
+
else
|
50
|
+
response = FailureResponse.new(http_response)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
response
|
54
|
+
rescue *NETWORK_ERRORS => e
|
55
|
+
FailureResponse.new.tap do |response|
|
56
|
+
response.body = nil
|
57
|
+
response.code = nil
|
58
|
+
response.error = e
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def build_http_request(request)
|
64
|
+
Net::HTTP::Post.new(request.uri.to_s).tap do |http_request|
|
65
|
+
http_request.body = request.body
|
66
|
+
|
67
|
+
request.headers.each do |key, value|
|
68
|
+
http_request[key] = value
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require "base64"
|
2
|
+
require "openssl"
|
3
|
+
|
4
|
+
module DynamoDB
|
5
|
+
class Request
|
6
|
+
class << self
|
7
|
+
def digest(signing_string, key)
|
8
|
+
Base64.encode64(
|
9
|
+
OpenSSL::HMAC.digest('sha256', key, Digest::SHA256.digest(signing_string))
|
10
|
+
).strip
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :uri, :datetime, :credentials, :region, :data, :service, :operation, :api_version
|
15
|
+
|
16
|
+
def initialize(args = {})
|
17
|
+
@uri = args[:uri]
|
18
|
+
@credentials = args[:credentials]
|
19
|
+
@operation = args[:operation]
|
20
|
+
@data = args[:data]
|
21
|
+
@api_version = args[:api_version]
|
22
|
+
@region = args[:region] || "us-east-1"
|
23
|
+
@datetime = Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
|
24
|
+
@service = "dynamodb"
|
25
|
+
end
|
26
|
+
|
27
|
+
def our_headers
|
28
|
+
{
|
29
|
+
"user-agent" => "groupme/dynamodb",
|
30
|
+
"host" => uri.host,
|
31
|
+
"content-type" => "application/x-amz-json-1.0",
|
32
|
+
"content-length" => body.size,
|
33
|
+
"x-amz-date" => datetime,
|
34
|
+
"x-amz-target" => "#{api_version}.#{operation}",
|
35
|
+
"x-amz-content-sha256" => hexdigest(body || '')
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
def headers
|
40
|
+
@headers ||= our_headers.merge("authorization" => authorization)
|
41
|
+
end
|
42
|
+
|
43
|
+
def body
|
44
|
+
@body ||= MultiJson.dump(data)
|
45
|
+
end
|
46
|
+
|
47
|
+
def authorization
|
48
|
+
parts = []
|
49
|
+
parts << "AWS4-HMAC-SHA256 Credential=#{credentials.access_key_id}/#{credential_string}"
|
50
|
+
parts << "SignedHeaders=#{our_headers.keys.sort.join(";")}"
|
51
|
+
parts << "Signature=#{signature}"
|
52
|
+
parts.join(', ')
|
53
|
+
end
|
54
|
+
|
55
|
+
def signature
|
56
|
+
k_secret = credentials.secret_access_key
|
57
|
+
k_date = hmac("AWS4" + k_secret, datetime[0,8])
|
58
|
+
k_region = hmac(k_date, region)
|
59
|
+
k_service = hmac(k_region, service)
|
60
|
+
k_credentials = hmac(k_service, 'aws4_request')
|
61
|
+
hexhmac(k_credentials, string_to_sign)
|
62
|
+
end
|
63
|
+
|
64
|
+
def string_to_sign
|
65
|
+
parts = []
|
66
|
+
parts << 'AWS4-HMAC-SHA256'
|
67
|
+
parts << datetime
|
68
|
+
parts << credential_string
|
69
|
+
parts << hexdigest(canonical_request)
|
70
|
+
parts.join("\n")
|
71
|
+
end
|
72
|
+
|
73
|
+
def credential_string
|
74
|
+
parts = []
|
75
|
+
parts << datetime[0,8]
|
76
|
+
parts << region
|
77
|
+
parts << service
|
78
|
+
parts << 'aws4_request'
|
79
|
+
parts.join("/")
|
80
|
+
end
|
81
|
+
|
82
|
+
def canonical_request
|
83
|
+
parts = []
|
84
|
+
parts << "POST"
|
85
|
+
parts << uri.path
|
86
|
+
parts << uri.query
|
87
|
+
parts << our_headers.sort.map {|k, v| [k,v].join(':')}.join("\n") + "\n"
|
88
|
+
parts << "content-length;content-type;host;user-agent;x-amz-content-sha256;x-amz-date;x-amz-target"
|
89
|
+
parts << our_headers['x-amz-content-sha256']
|
90
|
+
parts.join("\n")
|
91
|
+
end
|
92
|
+
|
93
|
+
def hexdigest value
|
94
|
+
digest = Digest::SHA256.new
|
95
|
+
digest.update(value)
|
96
|
+
digest.hexdigest
|
97
|
+
end
|
98
|
+
|
99
|
+
def hmac key, value
|
100
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest::Digest.new('sha256'), key, value)
|
101
|
+
end
|
102
|
+
|
103
|
+
def hexhmac key, value
|
104
|
+
OpenSSL::HMAC.hexdigest(OpenSSL::Digest::Digest.new('sha256'), key, value)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|