dynamodb 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/.rspec +1 -0
- data/Gemfile +2 -0
- data/README.md +48 -0
- data/Rakefile +4 -0
- data/dynamodb.gemspec +20 -0
- data/lib/dynamodb.rb +89 -0
- data/lib/dynamodb/connection.rb +97 -0
- data/lib/dynamodb/credentials.rb +30 -0
- data/lib/dynamodb/response.rb +33 -0
- data/lib/dynamodb/security_token_service.rb +110 -0
- data/lib/dynamodb/typhoeus/request.rb +27 -0
- data/spec/dynamodb/connection_spec.rb +131 -0
- data/spec/dynamodb/credentials_spec.rb +22 -0
- data/spec/dynamodb/response_spec.rb +87 -0
- data/spec/dynamodb/security_token_service_spec.rb +97 -0
- data/spec/dynamodb_spec.rb +59 -0
- data/spec/spec_helper.rb +8 -0
- metadata +136 -0
data/.gitignore
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
DynamoDB
|
2
|
+
========
|
3
|
+
|
4
|
+
Communicate with [Amazon DynamoDB](http://aws.amazon.com/dynamodb/) from Ruby.
|
5
|
+
|
6
|
+
This is a lightweight alternative to the official Amazon AWS gem.
|
7
|
+
|
8
|
+
The goal of this library is to be a high performance driver, not an ORM.
|
9
|
+
|
10
|
+
Usage
|
11
|
+
-----
|
12
|
+
|
13
|
+
The API maps the DynamoDB API closely. Once the connection object is ready, all
|
14
|
+
requests are done through `#post`. The first argument is the name of the
|
15
|
+
operation, the second is a hash that will be converted to JSON and used as the
|
16
|
+
request body.
|
17
|
+
|
18
|
+
require 'dynamodb'
|
19
|
+
|
20
|
+
conn = DynamoDB::Connection.new 'ACCESS_KEY', 'SECRET_KEY'
|
21
|
+
|
22
|
+
conn.post :ListTables
|
23
|
+
=> {"TableNames" => ["someTable", "anotherTable"]}
|
24
|
+
|
25
|
+
conn.post :GetItem, {:TableName => "someTable", :Key => {:S => "someKey"}}
|
26
|
+
=> { ... }
|
27
|
+
|
28
|
+
TODO
|
29
|
+
----
|
30
|
+
|
31
|
+
* Implement Signature Version 4
|
32
|
+
|
33
|
+
Credits
|
34
|
+
-------
|
35
|
+
|
36
|
+
This project started as a fork of [Jedlik](https://github.com/hashmal/jedlik)
|
37
|
+
by [Jérémy Pinat](https://github.com/hashmal) but has significantly diverged.
|
38
|
+
|
39
|
+
License
|
40
|
+
-------
|
41
|
+
|
42
|
+
Copyright (c) 2011-2012 GroupMe, Inc.
|
43
|
+
|
44
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
45
|
+
|
46
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
47
|
+
|
48
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Rakefile
ADDED
data/dynamodb.gemspec
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'dynamodb'
|
3
|
+
s.version = '0.0.2'
|
4
|
+
s.summary = "Communicate with Amazon DynamoDB."
|
5
|
+
s.description = "Communicate with Amazon DynamoDB. Raw access to the full API without having to handle temporary credentials or HTTP requests by yourself."
|
6
|
+
s.authors = ["Brandon Keene", "Dave Yeu"]
|
7
|
+
s.email = ["bkeene@gmail.com", "dave@groupme.com"]
|
8
|
+
s.require_path = 'lib'
|
9
|
+
s.files = `git ls-files`.split("\n")
|
10
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
11
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
|
+
s.homepage = 'http://github.com/groupme/dynamodb'
|
13
|
+
|
14
|
+
s.add_runtime_dependency 'typhoeus', '0.4.2'
|
15
|
+
s.add_runtime_dependency 'multi_json', '1.3.7'
|
16
|
+
|
17
|
+
s.add_development_dependency 'rspec', '2.8.0'
|
18
|
+
s.add_development_dependency 'webmock', '1.8.11'
|
19
|
+
end
|
20
|
+
|
data/lib/dynamodb.rb
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
require 'typhoeus'
|
2
|
+
require 'time'
|
3
|
+
require 'base64'
|
4
|
+
require 'openssl'
|
5
|
+
require 'cgi'
|
6
|
+
require 'multi_json'
|
7
|
+
|
8
|
+
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
|
+
|
18
|
+
class ClientError < BaseError; end
|
19
|
+
class ServerError < BaseError; end
|
20
|
+
class TimeoutError < BaseError; end
|
21
|
+
class AuthenticationError < BaseError; end
|
22
|
+
|
23
|
+
require 'dynamodb/typhoeus/request'
|
24
|
+
require 'dynamodb/credentials'
|
25
|
+
require 'dynamodb/security_token_service'
|
26
|
+
require 'dynamodb/connection'
|
27
|
+
require 'dynamodb/response'
|
28
|
+
|
29
|
+
class << self
|
30
|
+
def serialize(object)
|
31
|
+
if object.kind_of?(Hash)
|
32
|
+
serialized = {}
|
33
|
+
object.each do |k, v|
|
34
|
+
next if blank?(v)
|
35
|
+
serialized[k.to_s] = encode_type(v)
|
36
|
+
end
|
37
|
+
serialized
|
38
|
+
else
|
39
|
+
encode_type(object)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def deserialize(object)
|
44
|
+
if object.values.first.kind_of?(Hash)
|
45
|
+
deserialized = {}
|
46
|
+
object.each do |k, value_hash|
|
47
|
+
deserialized[k] = decode_type(value_hash)
|
48
|
+
end
|
49
|
+
deserialized
|
50
|
+
else
|
51
|
+
decode_type(object)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def blank?(object)
|
58
|
+
if object.respond_to?(:empty?)
|
59
|
+
object.empty?
|
60
|
+
else
|
61
|
+
object.nil?
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def encode_type(value)
|
66
|
+
case value
|
67
|
+
when Numeric
|
68
|
+
{"N" => value.to_s}
|
69
|
+
when TrueClass, FalseClass
|
70
|
+
{"N" => (value ? 1 : 0).to_s}
|
71
|
+
when Time
|
72
|
+
{"N" => value.to_f.to_s}
|
73
|
+
else
|
74
|
+
{"S" => value.to_s}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def decode_type(value_hash)
|
79
|
+
k = value_hash.keys.first
|
80
|
+
v = value_hash.values.first
|
81
|
+
case k
|
82
|
+
when "N" then v.include?('.') ? v.to_f : v.to_i
|
83
|
+
when "S" then v.to_s
|
84
|
+
else
|
85
|
+
raise "Type not recoginized: #{k}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
module DynamoDB
|
2
|
+
# Establishes a connection to Amazon DynamoDB using credentials.
|
3
|
+
class Connection
|
4
|
+
DEFAULTS = {
|
5
|
+
:endpoint => 'dynamodb.us-east-1.amazonaws.com',
|
6
|
+
:timeout => 5000 # ms
|
7
|
+
}
|
8
|
+
|
9
|
+
# Acceptable `opts` keys are:
|
10
|
+
#
|
11
|
+
# :endpoint # DynamoDB endpoint to use.
|
12
|
+
# # Default: 'dynamodb.us-east-1.amazonaws.com'
|
13
|
+
#
|
14
|
+
def initialize(opts = {})
|
15
|
+
opts = DEFAULTS.merge 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])
|
21
|
+
else
|
22
|
+
raise ArgumentError.new("access_key_id and secret_access_key are required")
|
23
|
+
end
|
24
|
+
|
25
|
+
@endpoint = opts[:endpoint]
|
26
|
+
@timeout = opts[:timeout]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Create and send a request to DynamoDB.
|
30
|
+
# Returns a hash extracted from the response body.
|
31
|
+
#
|
32
|
+
# `operation` can be any DynamoDB operation. `data` is a hash that will be
|
33
|
+
# used as the request body (in JSON format). More info available at:
|
34
|
+
#
|
35
|
+
# http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide
|
36
|
+
#
|
37
|
+
def post(operation, data={})
|
38
|
+
credentials = @sts.credentials
|
39
|
+
|
40
|
+
request = new_request(credentials, operation, MultiJson.dump(data))
|
41
|
+
request.sign(credentials)
|
42
|
+
|
43
|
+
hydra.queue(request)
|
44
|
+
hydra.run
|
45
|
+
response = request.response
|
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
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def hydra
|
62
|
+
Typhoeus::Hydra.hydra
|
63
|
+
end
|
64
|
+
|
65
|
+
def new_request(credentials, operation, body)
|
66
|
+
Typhoeus::Request.new "https://#@endpoint/",
|
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
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module DynamoDB
|
2
|
+
class Credentials
|
3
|
+
attr_reader :access_key_id, :secret_access_key, :session_token
|
4
|
+
|
5
|
+
def self.from_hash(hash)
|
6
|
+
new(hash["access_key_id"], hash["secret_access_key"], hash["session_token"])
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(access_key_id, secret_access_key, session_token)
|
10
|
+
@access_key_id = access_key_id
|
11
|
+
@secret_access_key = secret_access_key
|
12
|
+
@session_token = session_token
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_hash
|
16
|
+
{
|
17
|
+
"access_key_id" => access_key_id,
|
18
|
+
"secret_access_key" => secret_access_key,
|
19
|
+
"session_token" => session_token
|
20
|
+
}
|
21
|
+
end
|
22
|
+
|
23
|
+
def ==(other)
|
24
|
+
self.class == other.class &&
|
25
|
+
access_key_id == other.access_key_id &&
|
26
|
+
secret_access_key == other.secret_access_key &&
|
27
|
+
session_token == other.session_token
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module DynamoDB
|
2
|
+
class Response
|
3
|
+
attr_reader :typhoeus_response
|
4
|
+
|
5
|
+
def initialize(typhoeus_response)
|
6
|
+
@typhoeus_response = typhoeus_response
|
7
|
+
end
|
8
|
+
|
9
|
+
def hash_key_element
|
10
|
+
DynamoDB.deserialize(json["LastEvaluatedKey"]["HashKeyElement"])
|
11
|
+
end
|
12
|
+
|
13
|
+
def range_key_element
|
14
|
+
DynamoDB.deserialize(json["LastEvaluatedKey"]["RangeKeyElement"])
|
15
|
+
end
|
16
|
+
|
17
|
+
def item
|
18
|
+
return unless json["Item"]
|
19
|
+
@item ||= DynamoDB.deserialize(json["Item"])
|
20
|
+
end
|
21
|
+
|
22
|
+
def items
|
23
|
+
return unless json["Items"]
|
24
|
+
@items ||= json["Items"].map { |i| DynamoDB.deserialize(i) }
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def json
|
30
|
+
@json ||= MultiJson.load(typhoeus_response.body)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
module DynamoDB
|
2
|
+
attr_writer :access_key_id
|
3
|
+
attr_writer :secret_acces_key
|
4
|
+
attr_writer :session_token
|
5
|
+
|
6
|
+
# SecurityTokenService automatically manages the creation and renewal of
|
7
|
+
# temporary AWS credentials.
|
8
|
+
#
|
9
|
+
# Usage:
|
10
|
+
#
|
11
|
+
# credentials = SecurityTokenService.new "id", "secret key"
|
12
|
+
# credentials.access_key_id # => String
|
13
|
+
# credentials.secret_access_key # => String
|
14
|
+
# credentials.session_token # => String
|
15
|
+
#
|
16
|
+
class SecurityTokenService
|
17
|
+
THIRTY_SIX_HOURS = 129600
|
18
|
+
|
19
|
+
# A SecurityTokenService is initialized for a single AWS user using his
|
20
|
+
# credentials.
|
21
|
+
def initialize(access_key_id, secret_access_key)
|
22
|
+
@_access_key_id = access_key_id
|
23
|
+
@_secret_access_key = secret_access_key
|
24
|
+
@credentials = nil
|
25
|
+
end
|
26
|
+
|
27
|
+
def credentials
|
28
|
+
obtain_credentials
|
29
|
+
@credentials
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def signature(authorization_params)
|
35
|
+
sign(string_to_sign(authorization_params))
|
36
|
+
end
|
37
|
+
|
38
|
+
# The last line needs to be a query string of all parameters
|
39
|
+
# in the request in alphabetical order.
|
40
|
+
def string_to_sign(authorization_params)
|
41
|
+
[
|
42
|
+
"GET",
|
43
|
+
"sts.amazonaws.com",
|
44
|
+
"/",
|
45
|
+
"AWSAccessKeyId=#{@_access_key_id}" +
|
46
|
+
"&Action=GetSessionToken" +
|
47
|
+
"&DurationSeconds=#{THIRTY_SIX_HOURS}" +
|
48
|
+
"&SignatureMethod=HmacSHA256" +
|
49
|
+
"&SignatureVersion=2" +
|
50
|
+
"&Timestamp=#{CGI.escape(authorization_params[:Timestamp])}" +
|
51
|
+
"&Version=2011-06-15"
|
52
|
+
].join("\n")
|
53
|
+
end
|
54
|
+
|
55
|
+
# Extract the contents of a given tag.
|
56
|
+
def get_tag(tag, string)
|
57
|
+
# Considering that the XML string received from STS is sane and always
|
58
|
+
# has the same simple structure, I think a simple regular expression
|
59
|
+
# can do the job (with the benefit of not adding a dependency on
|
60
|
+
# another library just for ONE method). I will switch to Nokogiri if
|
61
|
+
# needed.
|
62
|
+
string.match(/#{tag.to_s}>([^<]*)/)[1]
|
63
|
+
end
|
64
|
+
|
65
|
+
# Obtain temporary credentials, set to expire after 1 hour. If
|
66
|
+
# credentials were previously obtained, no request is made until they
|
67
|
+
# expire.
|
68
|
+
def obtain_credentials
|
69
|
+
return unless credentials_expired?
|
70
|
+
|
71
|
+
authorization_params = {
|
72
|
+
:Action => 'GetSessionToken',
|
73
|
+
:Timestamp => Time.now.utc.iso8601,
|
74
|
+
:Version => '2011-06-15',
|
75
|
+
:DurationSeconds => THIRTY_SIX_HOURS # 36 hour expiration
|
76
|
+
}
|
77
|
+
|
78
|
+
params = {
|
79
|
+
:AWSAccessKeyId => @_access_key_id,
|
80
|
+
:SignatureMethod => 'HmacSHA256',
|
81
|
+
:SignatureVersion => '2',
|
82
|
+
:Signature => signature(authorization_params)
|
83
|
+
}.merge(authorization_params)
|
84
|
+
|
85
|
+
response = Typhoeus::Request.get("https://sts.amazonaws.com", :params => params)
|
86
|
+
if response.success?
|
87
|
+
body = response.body
|
88
|
+
@expiration = Time.parse(get_tag(:Expiration, body))
|
89
|
+
@credentials = Credentials.new(
|
90
|
+
get_tag(:AccessKeyId, body),
|
91
|
+
get_tag(:SecretAccessKey, body),
|
92
|
+
get_tag(:SessionToken, body))
|
93
|
+
else
|
94
|
+
raise AuthenticationError.new(response)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Sign (HMAC-SHA256) a string using the secret key given at
|
99
|
+
# initialization.
|
100
|
+
def sign(string)
|
101
|
+
Base64.encode64(
|
102
|
+
OpenSSL::HMAC.digest('sha256', @_secret_access_key, string)
|
103
|
+
).strip
|
104
|
+
end
|
105
|
+
|
106
|
+
def credentials_expired?
|
107
|
+
@expiration.nil? || @expiration <= Time.now.utc
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Typhoeus
|
2
|
+
class Request
|
3
|
+
def sign(credentials)
|
4
|
+
headers.merge!('x-amzn-authorization' => "AWS3 AWSAccessKeyId=#{credentials.access_key_id},Algorithm=HmacSHA256,Signature=#{digest(credentials.secret_access_key)}")
|
5
|
+
end
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def digest(secret_key)
|
10
|
+
Base64.encode64(
|
11
|
+
OpenSSL::HMAC.digest('sha256', secret_key, Digest::SHA256.digest(string_to_sign))
|
12
|
+
).strip
|
13
|
+
end
|
14
|
+
|
15
|
+
def string_to_sign
|
16
|
+
"POST\n/\n\nhost:#{parsed_uri.host}\n#{amz_to_sts}\n#{body}"
|
17
|
+
end
|
18
|
+
|
19
|
+
def amz_to_sts
|
20
|
+
get_amz_headers.sort.map {|key, val| [key, val].join(':') + "\n"}.join
|
21
|
+
end
|
22
|
+
|
23
|
+
def get_amz_headers
|
24
|
+
headers.select {|key, val| key =~ /\Ax-amz-/}
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'benchmark'
|
3
|
+
|
4
|
+
describe DynamoDB::Connection do
|
5
|
+
let(:token_service) {
|
6
|
+
stub(:credentials =>
|
7
|
+
DynamoDB::Credentials.new(
|
8
|
+
"access_key_id",
|
9
|
+
"secret_access_key",
|
10
|
+
"session_token"
|
11
|
+
)
|
12
|
+
)
|
13
|
+
}
|
14
|
+
|
15
|
+
describe "#initialize" do
|
16
|
+
it "can be initialized with a token service" do
|
17
|
+
DynamoDB::Connection.new(:token_service => token_service)
|
18
|
+
end
|
19
|
+
|
20
|
+
it "can be initialized with an access key" do
|
21
|
+
DynamoDB::Connection.new(
|
22
|
+
:access_key_id => "id",
|
23
|
+
:secret_access_key => "secret"
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
context "no token service was provided" do
|
28
|
+
it "requires an access_key_id and secret_access_key" do
|
29
|
+
lambda {
|
30
|
+
DynamoDB::Connection.new
|
31
|
+
}.should raise_error(ArgumentError)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe "#post" do
|
37
|
+
let(:connection) { DynamoDB::Connection.new(:token_service => token_service) }
|
38
|
+
|
39
|
+
before do
|
40
|
+
Time.stub(:now => Time.at(1332635893)) # Sat Mar 24 20:38:13 -0400 2012
|
41
|
+
|
42
|
+
@url = "https://dynamodb.us-east-1.amazonaws.com/"
|
43
|
+
@headers = {
|
44
|
+
'Content-Type' => 'application/x-amz-json-1.0',
|
45
|
+
'Host' => 'dynamodb.us-east-1.amazonaws.com',
|
46
|
+
'X-Amz-Date' => 'Sun, 25 Mar 2012 00:38:13 GMT',
|
47
|
+
'X-Amz-Security-Token' => 'session_token',
|
48
|
+
'X-Amzn-Authorization' => 'AWS3 AWSAccessKeyId=access_key_id,Algorithm=HmacSHA256,Signature=2xa6v0WW+980q8Hgt+ym3/7C0D1DlkueGMugi1NWE+o='
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
it "signs and posts a request" do
|
53
|
+
@headers['X-Amz-Target'] = 'DynamoDB_20111205.ListTables'
|
54
|
+
stub_request(:post, @url).
|
55
|
+
with(
|
56
|
+
:body => "{}",
|
57
|
+
:headers => @headers
|
58
|
+
).
|
59
|
+
to_return(
|
60
|
+
:status => 200,
|
61
|
+
:body => '{"TableNames":["example"]}',
|
62
|
+
:headers => {}
|
63
|
+
)
|
64
|
+
|
65
|
+
result = connection.post :ListTables
|
66
|
+
result.should == {"TableNames" => ["example"]}
|
67
|
+
end
|
68
|
+
|
69
|
+
it "type casts response when Query" do
|
70
|
+
stub_request(:post, @url).
|
71
|
+
to_return(
|
72
|
+
:status => 200,
|
73
|
+
:body => "{}",
|
74
|
+
:headers => {}
|
75
|
+
)
|
76
|
+
|
77
|
+
response = connection.post :Query, :TableName => "people", :HashKeyId => {:N => "1"}
|
78
|
+
response.should be_a_kind_of(DynamoDB::Response)
|
79
|
+
end
|
80
|
+
|
81
|
+
it "type casts response when GetItem" do
|
82
|
+
stub_request(:post, @url).
|
83
|
+
to_return(
|
84
|
+
:status => 200,
|
85
|
+
:body => "{}",
|
86
|
+
:headers => {}
|
87
|
+
)
|
88
|
+
|
89
|
+
response = connection.post :GetItem, :TableName => "people", :Key => {:HashKeyElement => {:N => "1"}, :RangeKeyElement => {:N => 2}}
|
90
|
+
response.should be_a_kind_of(DynamoDB::Response)
|
91
|
+
end
|
92
|
+
|
93
|
+
context "when a failure occurs" do
|
94
|
+
it "raises an error with the response attached" do
|
95
|
+
stub_request(:post, @url).to_return(:status => 500, :body => "Failed for some reason.")
|
96
|
+
error = nil
|
97
|
+
begin
|
98
|
+
connection.post :Query, :TableName => "people", :HashKeyId => {:N => "1"}
|
99
|
+
rescue => e
|
100
|
+
error = e
|
101
|
+
end
|
102
|
+
error.should be_an_instance_of(DynamoDB::ServerError)
|
103
|
+
error.response.code.should == 500
|
104
|
+
error.message.should == "500: Failed for some reason."
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
context "when the connection fails" do
|
109
|
+
it "raises a server error" do
|
110
|
+
stub_request(:post, @url).to_return(:status => 0, :body => "")
|
111
|
+
error = nil
|
112
|
+
begin
|
113
|
+
connection.post :Query, :TableName => "people", :HashKeyId => {:N => "1"}
|
114
|
+
rescue => e
|
115
|
+
error = e
|
116
|
+
end
|
117
|
+
error.should be_an_instance_of(DynamoDB::ServerError)
|
118
|
+
error.response.code.should == 0
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
context "when the connection times out" do
|
123
|
+
it "raises a DynamoDB::TimeoutError" do
|
124
|
+
stub_request(:post, @url).to_timeout
|
125
|
+
expect {
|
126
|
+
connection.post :Query, :TableName => "people", :HashKeyId => {:N => "1"}
|
127
|
+
}.to raise_error(DynamoDB::TimeoutError)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe DynamoDB::Credentials do
|
4
|
+
describe "#==" do
|
5
|
+
it "is true for identical credentials" do
|
6
|
+
DynamoDB::Credentials.new("abc", "123", "token").should ==
|
7
|
+
DynamoDB::Credentials.new("abc", "123", "token")
|
8
|
+
end
|
9
|
+
|
10
|
+
it "is false otherwise" do
|
11
|
+
DynamoDB::Credentials.new("abc", "123", "token").should_not ==
|
12
|
+
DynamoDB::Credentials.new("abc", "123", "different token")
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
describe "#to_hash" do
|
17
|
+
it "converts to a hash and back" do
|
18
|
+
credentials = DynamoDB::Credentials.new("abc", "123", "token")
|
19
|
+
DynamoDB::Credentials.from_hash(credentials.to_hash).should == credentials
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe DynamoDB::Response do
|
4
|
+
before do
|
5
|
+
body = {
|
6
|
+
"LastEvaluatedKey" => {
|
7
|
+
"HashKeyElement" => {"N"=>"1"},
|
8
|
+
"RangeKeyElement"=>{"N"=>"1501"}
|
9
|
+
},
|
10
|
+
"Items"=>[
|
11
|
+
{
|
12
|
+
"name"=>{"S"=>"John Smith"},
|
13
|
+
"created_at"=>{"N"=>"1321564309.99428"},
|
14
|
+
"disabled"=>{"N"=>"0"},
|
15
|
+
"group_id"=>{"N"=>"1"},
|
16
|
+
"person_id"=>{"N" => "1500"}
|
17
|
+
},
|
18
|
+
{
|
19
|
+
"name"=>{"S"=>"Jane Smith"},
|
20
|
+
"created_at"=>{"N"=>"1321564309.99428"},
|
21
|
+
"disabled"=>{"N"=>"1"},
|
22
|
+
"group_id"=>{"N"=>"1"},
|
23
|
+
"person_id"=>{"N" => "1501"}
|
24
|
+
}
|
25
|
+
],
|
26
|
+
"Count" => 1,
|
27
|
+
"ConsumedCapacityUnits" => 0.5
|
28
|
+
}
|
29
|
+
@typhoeus_response = mock("response", :body => MultiJson.dump(body))
|
30
|
+
end
|
31
|
+
|
32
|
+
describe "#hash_key_element" do
|
33
|
+
it "returns the typecast value of HashKeyElement" do
|
34
|
+
response = DynamoDB::Response.new(@typhoeus_response)
|
35
|
+
response.hash_key_element.should == 1
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
describe "#range_key_element" do
|
40
|
+
it "returns the typecast value of RangeKeyElement" do
|
41
|
+
response = DynamoDB::Response.new(@typhoeus_response)
|
42
|
+
response.range_key_element.should == 1501
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
context "Items" do
|
47
|
+
it "type casts response" do
|
48
|
+
response = DynamoDB::Response.new(@typhoeus_response)
|
49
|
+
response.items[0]["name"].should == "John Smith"
|
50
|
+
response.items[0]["created_at"].should == 1321564309.99428
|
51
|
+
response.items[0]["disabled"].should == 0
|
52
|
+
response.items[0]["group_id"].should == 1
|
53
|
+
response.items[0]["person_id"].should == 1500
|
54
|
+
|
55
|
+
response.items[1]["name"].should == "Jane Smith"
|
56
|
+
response.items[1]["created_at"].should == 1321564309.99428
|
57
|
+
response.items[1]["disabled"].should == 1
|
58
|
+
response.items[1]["group_id"].should == 1
|
59
|
+
response.items[1]["person_id"].should == 1501
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
context "Item" do
|
64
|
+
before do
|
65
|
+
body = {
|
66
|
+
"Item"=> {
|
67
|
+
"name"=>{"S"=>"John Smith"},
|
68
|
+
"created_at"=>{"N"=>"1321564309.99428"},
|
69
|
+
"disabled"=>{"N"=>"0"},
|
70
|
+
"group_id"=>{"N"=>"1"},
|
71
|
+
"person_id"=>{"N" => "1500"}
|
72
|
+
},
|
73
|
+
"ConsumedCapacityUnits" => 0.5
|
74
|
+
}
|
75
|
+
@typhoeus_response = mock("response", :body => MultiJson.dump(body))
|
76
|
+
end
|
77
|
+
|
78
|
+
it "type casts response" do
|
79
|
+
response = DynamoDB::Response.new(@typhoeus_response)
|
80
|
+
response.item["name"].should == "John Smith"
|
81
|
+
response.item["created_at"].should == 1321564309.99428
|
82
|
+
response.item["disabled"].should == 0
|
83
|
+
response.item["group_id"].should == 1
|
84
|
+
response.item["person_id"].should == 1500
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
module DynamoDB
|
4
|
+
describe SecurityTokenService do
|
5
|
+
let(:sts) { SecurityTokenService.new("access_key_id", "secret_access_key") }
|
6
|
+
|
7
|
+
context "success" do
|
8
|
+
before do
|
9
|
+
Time.stub(:now).and_return(Time.parse("2012-03-24T22:10:38Z"))
|
10
|
+
success_body = <<-XML
|
11
|
+
<GetSessionTokenResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
|
12
|
+
<GetSessionTokenResult>
|
13
|
+
<Credentials>
|
14
|
+
<SessionToken>session_token</SessionToken>
|
15
|
+
<SecretAccessKey>secret_access_key</SecretAccessKey>
|
16
|
+
<Expiration>2036-03-19T01:03:22.276Z</Expiration>
|
17
|
+
<AccessKeyId>access_key_id</AccessKeyId>
|
18
|
+
</Credentials>
|
19
|
+
</GetSessionTokenResult>
|
20
|
+
<ResponseMetadata>
|
21
|
+
<RequestId>f0fa5827-7156-11e1-8f1e-a92b58fdc66e</RequestId>
|
22
|
+
</ResponseMetadata>
|
23
|
+
</GetSessionTokenResponse>
|
24
|
+
XML
|
25
|
+
|
26
|
+
@request = stub_request(:get, "https://sts.amazonaws.com/").
|
27
|
+
with(:query => {
|
28
|
+
"AWSAccessKeyId" => "access_key_id",
|
29
|
+
"Action" => "GetSessionToken",
|
30
|
+
"Signature" => "HyF65paDxprCe+zEHx7wxah+hiZ43PhvtmeehDtfuw8=",
|
31
|
+
"SignatureMethod" => "HmacSHA256",
|
32
|
+
"SignatureVersion" => "2",
|
33
|
+
"Timestamp" => "2012-03-24T22:10:38Z",
|
34
|
+
"Version" => "2011-06-15",
|
35
|
+
"DurationSeconds" => "129600"
|
36
|
+
}).to_return(:status => 200, :body => success_body)
|
37
|
+
end
|
38
|
+
|
39
|
+
it "obtains session_token, access_key_id, secret_access_key" do
|
40
|
+
sts = SecurityTokenService.new("access_key_id", "secret_access_key")
|
41
|
+
credentials = sts.credentials
|
42
|
+
credentials.access_key_id.should == "access_key_id"
|
43
|
+
credentials.secret_access_key.should == "secret_access_key"
|
44
|
+
credentials.session_token.should == "session_token"
|
45
|
+
end
|
46
|
+
|
47
|
+
it "does not query STS if the credentials are not expired" do
|
48
|
+
sts = SecurityTokenService.new("access_key_id", "secret_access_key")
|
49
|
+
|
50
|
+
sts.stub(:credentials_expired?).and_return(true)
|
51
|
+
sts.credentials
|
52
|
+
@request.should have_been_requested
|
53
|
+
|
54
|
+
WebMock.reset!
|
55
|
+
|
56
|
+
sts.stub(:credentials_expired?).and_return(false)
|
57
|
+
sts.credentials
|
58
|
+
@request.should_not have_been_requested
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
context "failure" do
|
63
|
+
before do
|
64
|
+
Time.stub(:now).and_return(Time.parse("2012-03-24T22:10:38Z"))
|
65
|
+
error_body = <<-XML
|
66
|
+
<ErrorResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
|
67
|
+
<Error>
|
68
|
+
<Type>Sender</Type>
|
69
|
+
<Code>InvalidClientTokenId</Code>
|
70
|
+
<Message>The security token included in the request is invalid</Message>
|
71
|
+
</Error>
|
72
|
+
<RequestId>a9f51cd0-7f5b-11e1-8022-bd0f2fc51c4b</RequestId>
|
73
|
+
</ErrorResponse>
|
74
|
+
XML
|
75
|
+
|
76
|
+
stub_request(:get, "https://sts.amazonaws.com/").
|
77
|
+
with(:query => {
|
78
|
+
"AWSAccessKeyId" => "access_key_id",
|
79
|
+
"Action" => "GetSessionToken",
|
80
|
+
"Signature" => "HyF65paDxprCe+zEHx7wxah+hiZ43PhvtmeehDtfuw8=",
|
81
|
+
"SignatureMethod" => "HmacSHA256",
|
82
|
+
"SignatureVersion" => "2",
|
83
|
+
"Timestamp" => "2012-03-24T22:10:38Z",
|
84
|
+
"Version" => "2011-06-15",
|
85
|
+
"DurationSeconds" => "129600"
|
86
|
+
}).to_return(:status => 403, :body => error_body)
|
87
|
+
end
|
88
|
+
|
89
|
+
it "raises an AuthenticationError" do
|
90
|
+
s = SecurityTokenService.new("access_key_id", "secret_access_key")
|
91
|
+
proc {
|
92
|
+
s.credentials
|
93
|
+
}.should raise_error(DynamoDB::AuthenticationError)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe DynamoDB do
|
4
|
+
describe "#serialize" do
|
5
|
+
it "serializes to dynamo type format" do
|
6
|
+
published_at = Time.now
|
7
|
+
hash = {
|
8
|
+
:id => 1,
|
9
|
+
:name => "Gone with the Wind",
|
10
|
+
:published_at => published_at,
|
11
|
+
:price => 11.99,
|
12
|
+
:active => true
|
13
|
+
}
|
14
|
+
|
15
|
+
DynamoDB.serialize(hash).should == {
|
16
|
+
"id" => {"N" => "1"},
|
17
|
+
"name" => {"S" => "Gone with the Wind"},
|
18
|
+
"published_at" => {"N" => published_at.to_f.to_s},
|
19
|
+
"price" => {"N" => "11.99"},
|
20
|
+
"active" => {"N" => "1"}
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
24
|
+
it "serializes a single value" do
|
25
|
+
DynamoDB.serialize(1).should == {"N" => "1"}
|
26
|
+
DynamoDB.serialize(1.5).should == {"N" => "1.5"}
|
27
|
+
DynamoDB.serialize("Hello World").should == {"S" => "Hello World"}
|
28
|
+
end
|
29
|
+
|
30
|
+
it "omits nil/blank values" do
|
31
|
+
DynamoDB.serialize("foo" => nil).should == {}
|
32
|
+
DynamoDB.serialize("foo" => "").should == {}
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe "#deserialize" do
|
37
|
+
it "deserializes single values" do
|
38
|
+
DynamoDB.deserialize({"N" => "123"}).should == 123
|
39
|
+
DynamoDB.deserialize({"S" => "Hello World"}).should == "Hello World"
|
40
|
+
end
|
41
|
+
|
42
|
+
it "deserializes from dynamo type format" do
|
43
|
+
published_at = Time.now
|
44
|
+
item = {
|
45
|
+
"id" => {"N" => "1"},
|
46
|
+
"name" => {"S" => "Gone with the Wind"},
|
47
|
+
"published_at" => {"N" => published_at.to_f.to_s},
|
48
|
+
"price" => {"N" => "11.99"},
|
49
|
+
"active" => {"N" => "1"}
|
50
|
+
}
|
51
|
+
deserialized = DynamoDB.deserialize(item)
|
52
|
+
deserialized["id"].should == 1
|
53
|
+
deserialized["name"].should == "Gone with the Wind"
|
54
|
+
deserialized["published_at"].to_s.should == published_at.to_f.to_s
|
55
|
+
deserialized["price"].should == 11.99
|
56
|
+
deserialized["active"].should == 1
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dynamodb
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.2
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Brandon Keene
|
9
|
+
- Dave Yeu
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2012-11-15 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: typhoeus
|
17
|
+
requirement: !ruby/object:Gem::Requirement
|
18
|
+
none: false
|
19
|
+
requirements:
|
20
|
+
- - '='
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 0.4.2
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
none: false
|
27
|
+
requirements:
|
28
|
+
- - '='
|
29
|
+
- !ruby/object:Gem::Version
|
30
|
+
version: 0.4.2
|
31
|
+
- !ruby/object:Gem::Dependency
|
32
|
+
name: multi_json
|
33
|
+
requirement: !ruby/object:Gem::Requirement
|
34
|
+
none: false
|
35
|
+
requirements:
|
36
|
+
- - '='
|
37
|
+
- !ruby/object:Gem::Version
|
38
|
+
version: 1.3.7
|
39
|
+
type: :runtime
|
40
|
+
prerelease: false
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
42
|
+
none: false
|
43
|
+
requirements:
|
44
|
+
- - '='
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: 1.3.7
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: rspec
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - '='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 2.8.0
|
55
|
+
type: :development
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: !ruby/object:Gem::Requirement
|
58
|
+
none: false
|
59
|
+
requirements:
|
60
|
+
- - '='
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: 2.8.0
|
63
|
+
- !ruby/object:Gem::Dependency
|
64
|
+
name: webmock
|
65
|
+
requirement: !ruby/object:Gem::Requirement
|
66
|
+
none: false
|
67
|
+
requirements:
|
68
|
+
- - '='
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: 1.8.11
|
71
|
+
type: :development
|
72
|
+
prerelease: false
|
73
|
+
version_requirements: !ruby/object:Gem::Requirement
|
74
|
+
none: false
|
75
|
+
requirements:
|
76
|
+
- - '='
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: 1.8.11
|
79
|
+
description: Communicate with Amazon DynamoDB. Raw access to the full API without
|
80
|
+
having to handle temporary credentials or HTTP requests by yourself.
|
81
|
+
email:
|
82
|
+
- bkeene@gmail.com
|
83
|
+
- dave@groupme.com
|
84
|
+
executables: []
|
85
|
+
extensions: []
|
86
|
+
extra_rdoc_files: []
|
87
|
+
files:
|
88
|
+
- .gitignore
|
89
|
+
- .rspec
|
90
|
+
- Gemfile
|
91
|
+
- README.md
|
92
|
+
- Rakefile
|
93
|
+
- dynamodb.gemspec
|
94
|
+
- lib/dynamodb.rb
|
95
|
+
- lib/dynamodb/connection.rb
|
96
|
+
- lib/dynamodb/credentials.rb
|
97
|
+
- lib/dynamodb/response.rb
|
98
|
+
- lib/dynamodb/security_token_service.rb
|
99
|
+
- lib/dynamodb/typhoeus/request.rb
|
100
|
+
- spec/dynamodb/connection_spec.rb
|
101
|
+
- spec/dynamodb/credentials_spec.rb
|
102
|
+
- spec/dynamodb/response_spec.rb
|
103
|
+
- spec/dynamodb/security_token_service_spec.rb
|
104
|
+
- spec/dynamodb_spec.rb
|
105
|
+
- spec/spec_helper.rb
|
106
|
+
homepage: http://github.com/groupme/dynamodb
|
107
|
+
licenses: []
|
108
|
+
post_install_message:
|
109
|
+
rdoc_options: []
|
110
|
+
require_paths:
|
111
|
+
- lib
|
112
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
119
|
+
none: false
|
120
|
+
requirements:
|
121
|
+
- - ! '>='
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: '0'
|
124
|
+
requirements: []
|
125
|
+
rubyforge_project:
|
126
|
+
rubygems_version: 1.8.23
|
127
|
+
signing_key:
|
128
|
+
specification_version: 3
|
129
|
+
summary: Communicate with Amazon DynamoDB.
|
130
|
+
test_files:
|
131
|
+
- spec/dynamodb/connection_spec.rb
|
132
|
+
- spec/dynamodb/credentials_spec.rb
|
133
|
+
- spec/dynamodb/response_spec.rb
|
134
|
+
- spec/dynamodb/security_token_service_spec.rb
|
135
|
+
- spec/dynamodb_spec.rb
|
136
|
+
- spec/spec_helper.rb
|