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