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 CHANGED
@@ -1,2 +1,3 @@
1
1
  .rvmrc
2
+ .rbenv*
2
3
  Gemfile.lock
data/Gemfile CHANGED
@@ -1,2 +1,3 @@
1
- source :rubygems
1
+ source "https://rubygems.org"
2
+ gem "rake"
2
3
  gemspec
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 :ListTables
23
- => {"TableNames" => ["someTable", "anotherTable"]}
34
+ > response = conn.post(:GetItem, {:TableName => "my-dynamo-table", :Key => {:S => "some-key"}})
35
+ => #<DynamoDB::SuccessResponse:...>
24
36
 
25
- conn.post :GetItem, {:TableName => "someTable", :Key => {:S => "someKey"}}
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 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.
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.
@@ -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 = '0.0.2'
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'
@@ -1,30 +1,22 @@
1
- require 'typhoeus'
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
- require 'dynamodb/typhoeus/request'
24
- require 'dynamodb/credentials'
25
- require 'dynamodb/security_token_service'
12
+ Credentials = Struct.new(:access_key_id, :secret_access_key)
13
+
14
+ require 'dynamodb/version'
26
15
  require 'dynamodb/connection'
27
- require 'dynamodb/response'
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
@@ -1,97 +1,61 @@
1
1
  module DynamoDB
2
2
  # Establishes a connection to Amazon DynamoDB using credentials.
3
3
  class Connection
4
- DEFAULTS = {
5
- :endpoint => 'dynamodb.us-east-1.amazonaws.com',
6
- :timeout => 5000 # ms
7
- }
4
+ class << self
5
+ def http_handler
6
+ @http_handler ||= HttpHandler.new
7
+ end
8
8
 
9
- # Acceptable `opts` keys are:
10
- #
11
- # :endpoint # DynamoDB endpoint to use.
12
- # # Default: 'dynamodb.us-east-1.amazonaws.com'
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 = 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])
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
- @endpoint = opts[:endpoint]
26
- @timeout = opts[:timeout]
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
- # Returns a hash extracted from the response body.
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
- 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
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 hydra
62
- Typhoeus::Hydra.hydra
53
+ def http_handler
54
+ self.class.http_handler
63
55
  end
64
56
 
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
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