bisques 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,70 @@
1
+ Bisques is a client library for Amazon SQS (Simple Queue Service). It is
2
+ implemented using httpclient (https://github.com/nahi/httpclient).
3
+
4
+ * http://github.com/jemmyw/bisques
5
+
6
+ == USAGE
7
+
8
+ To interact with SQS initialize a Bisques::Client object.
9
+
10
+ The following scripts will result in the producer printing 10 numbers from the
11
+ fibonacci sequence, with the consumer script doing the actual calculation.
12
+
13
+ === Producer
14
+
15
+ require 'bisques'
16
+
17
+ Bisques::AwsCredentials.default(aws_key, aws_secret)
18
+ client = Bisques::Client.new('us-east-1')
19
+ number_queue = client.get_or_create_queue('numbers')
20
+ result_queue = client.get_or_create_queue('results')
21
+
22
+ 1.upto(10).each{|n| number_queue.post_message({"number" => n}) }
23
+
24
+ 10.times do
25
+ puts result_queue.retrieve_one.inspect
26
+ end
27
+
28
+ === Consumer
29
+
30
+ require 'bisques'
31
+
32
+ Bisques::AwsCredentials.default(aws_key, aws_secret)
33
+ client = Bisques::Client.new('us-east-1')
34
+ number_queue = client.get_or_create_queue('numbers')
35
+ result_queue = client.get_or_create_queue('results')
36
+
37
+ listener = Bisques::QueueListener.new(number_queue)
38
+ listener.listen do |message|
39
+ result = fib(message["number"])
40
+ result_queue.post_message({"result" => result})
41
+ message.delete
42
+ end
43
+
44
+ while true; sleep 1; end
45
+
46
+ == LICENSE
47
+
48
+ Copyright (c) 2012 Jeremy Wells
49
+
50
+ Permission is hereby granted, free of charge, to any person
51
+ obtaining a copy of this software and associated documentation
52
+ files (the "Software"), to deal in the Software without
53
+ restriction, including without limitation the rights to use,
54
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
55
+ copies of the Software, and to permit persons to whom the
56
+ Software is furnished to do so, subject to the following
57
+ conditions:
58
+
59
+ The above copyright notice and this permission notice shall be
60
+ included in all copies or substantial portions of the Software.
61
+
62
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
63
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
64
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
65
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
66
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
67
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
68
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
69
+ OTHER DEALINGS IN THE SOFTWARE.
70
+
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
@@ -0,0 +1,100 @@
1
+ require 'httpclient'
2
+ require 'bisques/aws_request'
3
+ require 'bisques/aws_credentials'
4
+
5
+ module Bisques
6
+ class AwsActionError < Bisques::Error
7
+ attr_reader :type, :code, :message, :status
8
+
9
+ def initialize(type, code, message, status)
10
+ @type, @code, @message, @status = type, code, message, status
11
+ super(message)
12
+ end
13
+
14
+ def to_s
15
+ "HTTP #{status}: #{type} #{code} #{message}"
16
+ end
17
+ end
18
+
19
+ # This module is for making API classes more convenient. The including class
20
+ # must pass the correct params via super from it's #initialize call. Two
21
+ # useful methods are added to the including class, #request and #action.
22
+ module AwsConnection
23
+ def self.included(mod) # :nodoc:
24
+ mod.class_eval do
25
+ attr_accessor :credentials, :region, :service
26
+ end
27
+ end
28
+
29
+ # Give the region, service and optionally the AwsCredentials.
30
+ #
31
+ # === Example:
32
+ #
33
+ # class Sqs
34
+ # include AwsConnection
35
+ #
36
+ # def initialize(region)
37
+ # super(region, 'sqs')
38
+ # end
39
+ # end
40
+ #
41
+ def initialize(region, service, credentials = AwsCredentials.default)
42
+ @region, @service, @credentials = region, service, credentials
43
+ end
44
+
45
+ def connection # :nodoc:
46
+ @connection ||= HTTPClient.new.tap do |http|
47
+ http.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE
48
+ http.receive_timeout = 30
49
+ end
50
+ end
51
+
52
+ # Perform an HTTP query to the given path using the given method (GET,
53
+ # POST, PUT, DELETE). A hash of query params can be specified. A POST or
54
+ # PUT body cna be specified as either a string or a hash of form params. A
55
+ # hash of HTTP headers can be specified.
56
+ def request(method, path, query = {}, body = nil, headers = {})
57
+ AwsRequest.new(connection).tap do |aws_request|
58
+ aws_request.credentials = credentials
59
+ aws_request.path = path
60
+ aws_request.region = region
61
+ aws_request.service = service
62
+ aws_request.method = method
63
+ aws_request.query = query
64
+ aws_request.body = body
65
+ aws_request.headers = headers
66
+ aws_request.make_request
67
+ end
68
+ end
69
+
70
+ # Call an AWS API with the given name at the given path. An optional hash
71
+ # of options can be passed as arguments for the API call. Returns an
72
+ # AwsResponse. If the response is not successful then an AwsActionError is
73
+ # raised and the error information is extracted into the exception
74
+ # instance.
75
+ #
76
+ # The API call will be automatically retried if the returned status code is
77
+ # in the 500 range.
78
+ def action(action_name, path = "/", options = {})
79
+ retries = 0
80
+
81
+ begin
82
+ # If options given in place of path assume /
83
+ options, path = path, "/" if path.is_a?(Hash) && options.empty?
84
+ request(:post, path, {}, options.merge("Action" => action_name)).response.tap do |response|
85
+ unless response.success?
86
+ element = response.doc.xpath("//Error")
87
+ raise AwsActionError.new(element.xpath("Type").text, element.xpath("Code").text, element.xpath("Message").text, response.http_response.status)
88
+ end
89
+ end
90
+ rescue AwsActionError => e
91
+ if retries < 2 && (500..599).include?(e.status.to_i)
92
+ retries += 1
93
+ retry
94
+ else
95
+ raise e
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,28 @@
1
+ module Bisques
2
+ # Represents an AWS key/secret combination. Provides a convenient class
3
+ # method for setting defaults that can be used by all objects later on.
4
+ #
5
+ # Example:
6
+ #
7
+ # AwsCredentials.default('aws_key', 'aws_secret')
8
+ #
9
+ class AwsCredentials
10
+ attr_reader :aws_key, :aws_secret
11
+
12
+ def initialize(aws_key, aws_secret)
13
+ @aws_key, @aws_secret = aws_key, aws_secret
14
+ end
15
+
16
+ class << self
17
+ def default(*args)
18
+ if args.size == 2
19
+ @default = AwsCredentials.new(*args)
20
+ elsif args.empty?
21
+ @default
22
+ else
23
+ raise ArgumentError, "default takes 0 or 2 arguments"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,114 @@
1
+ require 'bisques/aws_request_authorization'
2
+ require 'bisques/aws_response'
3
+
4
+ module Bisques
5
+ # A request to an AWS API call. This class must be initiated with a client
6
+ # instance of HTTPClient. A number of mandatory attributes must be set before
7
+ # calling make_request to return the response. #make_request returns an
8
+ # AwsResponse object.
9
+ #
10
+ # === Example
11
+ #
12
+ # request = AwsRequest.new(httpclient)
13
+ # request.method = "GET" or "POST"
14
+ # request.query = {"hash" => "of query params"}
15
+ # request.body = {"hash" => "of form params"} or "text body"
16
+ # request.headers = {"hash" => "of optional headers"}
17
+ # request.path = "optional path"
18
+ # request.region = "AWS region (ex. us-east-1)"
19
+ # request.service = "AWS service (ex. sqs)"
20
+ # request.credentials = AwsCredentials.new("aws_key", "aws_secret")
21
+ # response = request.make_request => Returns AwsResponse
22
+ #
23
+ class AwsRequest
24
+ # The HTTP method to use. Should be GET or POST.
25
+ attr_accessor :method
26
+ # A hash describing the query params to send.
27
+ attr_accessor :query
28
+ # A hash or string describing the form params or HTTP body. Only used when
29
+ # the method is POST or PUT.
30
+ attr_accessor :body
31
+ # A hash of additional HTTP headers to send with the request.
32
+ attr_accessor :headers
33
+ # The path to the API call. This shouldn't be the full URL as the host part
34
+ # is built from the region and service.
35
+ attr_accessor :path
36
+ # The AWS region. Ex: us-east-1
37
+ attr_accessor :region
38
+ # The AWS service. Ex: sqs
39
+ attr_accessor :service
40
+ # The AWS credentials. Should respond to aws_key and aws_secret. Use
41
+ # AwsCredentials.
42
+ attr_accessor :credentials
43
+
44
+ # An AwsResponse object describing the response. Returns nil until
45
+ # #make_request has been called.
46
+ attr_reader :response
47
+ attr_reader :authorization # :nodoc:
48
+
49
+ # AWS has some particular rules about how it likes it's form params encoded.
50
+ def self.aws_encode(value)
51
+ value.to_s.gsub(/([^a-zA-Z0-9._~-]+)/n) do
52
+ '%' + $1.unpack('H2' * $1.size).join('%').upcase
53
+ end
54
+ end
55
+
56
+ # Create a new AwsRequest using the given HTTPClient object.
57
+ def initialize(httpclient)
58
+ @httpclient = httpclient
59
+ end
60
+
61
+ # The full URL to the API endpoint the request will call.
62
+ def url
63
+ File.join("https://#{service}.#{region}.amazonaws.com", path)
64
+ end
65
+
66
+ # Send the HTTP request and get a response. Returns an AwsResponse object.
67
+ # The instance is frozen once this method is called and cannot be used
68
+ # again.
69
+ def make_request
70
+ create_authorization
71
+
72
+ options = {}
73
+ options[:header] = authorization.headers.merge(
74
+ 'Authorization' => authorization.authorization_header
75
+ )
76
+ options[:query] = query if query.any?
77
+ options[:body] = form_body if body
78
+
79
+ http_response = @httpclient.request(method, url, options)
80
+ @response = AwsResponse.new(self, http_response)
81
+
82
+ freeze
83
+
84
+ @response
85
+ end
86
+
87
+ private
88
+
89
+ # Encode the form params if the body is given as a Hash.
90
+ def form_body
91
+ if body.is_a?(Hash)
92
+ body.map do |k,v|
93
+ [AwsRequest.aws_encode(k), AwsRequest.aws_encode(v)].join("=")
94
+ end.join("&")
95
+ else
96
+ body
97
+ end
98
+ end
99
+
100
+ # Create the AwsRequestAuthorization object for the request.
101
+ def create_authorization
102
+ @authorization = AwsRequestAuthorization.new.tap do |authorization|
103
+ authorization.url = url
104
+ authorization.method = method
105
+ authorization.query = query
106
+ authorization.body = form_body
107
+ authorization.region = region
108
+ authorization.service = service
109
+ authorization.credentials = credentials
110
+ authorization.headers = headers
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,174 @@
1
+ require "openssl"
2
+
3
+ module Bisques
4
+ # Instances of this class are used to create HTTP authorization headers for
5
+ # AWS using Signature Version 4, as per
6
+ # http://docs.amazonwebservices.com/general/latest/gr/signature-version-4.html
7
+ #
8
+ # === Usage
9
+ #
10
+ # This is just an example of extracting the relevant information.
11
+ #
12
+ # url = "https://sqs.us-east-1.amazon.com/"
13
+ # original_headers = {"Content-Type": "text/plain"}
14
+ # query = {"Action" => "ListQueues"}
15
+ # body = "some body text"
16
+ # credentials = AwsCredentials.default
17
+ #
18
+ # authorization = AwsRequestAuthorization.new
19
+ # authorization.url = url
20
+ # authorization.method = "POST"
21
+ # authorization.query = query
22
+ # authorization.body = body
23
+ # authorization.region = "us-east-1"
24
+ # authorization.service = "sqs"
25
+ # authorization.headers = original_headers
26
+ #
27
+ # new_headers = authorization.headers.merge(
28
+ # "Authorization" => authorization.authorization_header
29
+ # )
30
+ #
31
+ # url_with_query = url + "?" + query.map{|p| p.join("=")}.join("&")
32
+ #
33
+ # Net::HTTP.post(
34
+ # url_with_query,
35
+ # body,
36
+ # new_headers
37
+ # )
38
+ #
39
+ class AwsRequestAuthorization
40
+ # The full URL to the API call.
41
+ attr_accessor :url
42
+ # The HTTP method.
43
+ attr_accessor :method
44
+ # A hash of key/pairs for the query string.
45
+ attr_accessor :query
46
+ # A string of the body being sent (for POST and PUT HTTP methods).
47
+ attr_accessor :body
48
+ # The AWS region.
49
+ attr_accessor :region
50
+ # The AWS service.
51
+ attr_accessor :service
52
+ # An AwsCredentials object.
53
+ attr_accessor :credentials
54
+ # The headers to read back.
55
+ attr_reader :headers
56
+
57
+ # The generated authorization header.
58
+ def authorization_header
59
+ [
60
+ "AWS4-HMAC-SHA256",
61
+ "Credential=#{credentials.aws_key}/#{request_datestamp}/#{region}/#{service}/aws4_request,",
62
+ "SignedHeaders=#{signed_headers.join(";")},",
63
+ "Signature=#{signature.to_s}"
64
+ ].join(" ")
65
+ end
66
+
67
+ # The HTTP headers being sent.
68
+ def headers=(headers={})
69
+ @headers = headers.merge(
70
+ "x-amz-date" => request_timestamp
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ # When first called set a time for the request and keep it.
77
+ def request_time
78
+ @request_time ||= Time.now.utc
79
+ end
80
+
81
+ # The AWS timestamp format.
82
+ def request_timestamp
83
+ request_time.strftime("%Y%m%dT%H%M%SZ")
84
+ end
85
+
86
+ # The AWS datestamp format.
87
+ def request_datestamp
88
+ request_time.strftime("%Y%m%d")
89
+ end
90
+
91
+ # The digest used is SHA2.
92
+ def digest
93
+ Digest::SHA2.new
94
+ end
95
+
96
+ # Task 3: Calculate the signature.
97
+ def signature
98
+ digest = "SHA256"
99
+ OpenSSL::HMAC.hexdigest(digest, signing_key, string_to_sign)
100
+ end
101
+
102
+ # Calculate the signing key for task 3.
103
+ def signing_key
104
+ digest = "SHA256"
105
+ kDate = OpenSSL::HMAC.digest(digest, "AWS4" + credentials.aws_secret, request_datestamp)
106
+ kRegion = OpenSSL::HMAC.digest(digest, kDate, region)
107
+ kService = OpenSSL::HMAC.digest(digest, kRegion, service)
108
+ OpenSSL::HMAC.digest(digest, kService, "aws4_request")
109
+ end
110
+
111
+ # Task 2: Create the string to sign.
112
+ def string_to_sign
113
+ [
114
+ "AWS4-HMAC-SHA256",
115
+ request_timestamp,
116
+ credential_scope,
117
+ digest.hexdigest(canonical)
118
+ ].join("\n")
119
+ end
120
+
121
+ # Task 1: Create a canonical request.
122
+ def canonical
123
+ canonical = ""
124
+ canonical << method.to_s.upcase << "\n"
125
+ canonical << uri.path << "\n"
126
+
127
+ canonical_query.each_with_index do |(param,value), index|
128
+ canonical << param << "=" << value
129
+ canonical << "&" unless index == query.size - 1
130
+ end
131
+
132
+ canonical << "\n"
133
+ canonical << canonical_headers.map{|h| h.join(":")}.join("\n")
134
+ canonical << "\n\n"
135
+ canonical << signed_headers.join(";")
136
+ canonical << "\n"
137
+
138
+ canonical << digest.hexdigest(body.to_s).downcase
139
+ canonical
140
+ end
141
+
142
+ # List of signed headers.
143
+ def signed_headers
144
+ canonical_headers.map{|name,value| name}
145
+ end
146
+
147
+ # Credential scope for task 2.
148
+ def credential_scope
149
+ [request_datestamp,
150
+ region,
151
+ service,
152
+ "aws4_request"].join("/")
153
+ end
154
+
155
+ # Canonical query for task 1. Uses the AwsRequest::aws_encode for AWS
156
+ # encoding rules.
157
+ def canonical_query
158
+ query.map{|param,value| [AwsRequest.aws_encode(param), AwsRequest.aws_encode(value)]}.sort
159
+ end
160
+
161
+ # The canonical headers, including the Host.
162
+ def canonical_headers
163
+ hash = headers.dup
164
+ hash["host"] ||= Addressable::URI.parse(url).host
165
+ hash = hash.map{|name,value| [name.downcase,value]}
166
+ hash.reject!{|name,value| name == "authorization"}
167
+ hash.sort
168
+ end
169
+
170
+ def uri
171
+ Addressable::URI.parse(url)
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,36 @@
1
+ require 'nokogiri'
2
+
3
+ module Bisques
4
+ # Created by an AwsRequest to represent the returned details. The original
5
+ # request is stored in #request. The raw content is available in #content.
6
+ # AWS returns XML, and a Nokogiri::XML instance is available in #doc.
7
+ class AwsResponse
8
+ # The original AwsRequest that created this response.
9
+ attr_reader :request
10
+ # The raw response string from AWS
11
+ attr_reader :content
12
+ # The HTTP response. This can be used to get any headers or status codes.
13
+ attr_reader :http_response
14
+
15
+ def initialize(request, http_response) # :nodoc:
16
+ @request = request
17
+ @http_response = http_response
18
+ @content = @http_response.body
19
+ end
20
+
21
+ # A Nokogiri::XML document parsed from the #content.
22
+ def doc
23
+ @doc ||= Nokogiri::XML(content).tap{|x|x.remove_namespaces!}
24
+ end
25
+
26
+ # Returns true if the request was successful.
27
+ def success?
28
+ @http_response.ok?
29
+ end
30
+
31
+ # The request ID from AWS.
32
+ def request_id
33
+ @http_response.header['x-amzn-RequestId']
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,148 @@
1
+ require 'bisques/aws_connection'
2
+ require 'bisques/aws_credentials'
3
+ require 'bisques/queue'
4
+ require 'bisques/queue_listener'
5
+ require 'digest/md5'
6
+
7
+ module Bisques
8
+ # Bisques is a client for Amazon SQS. All of the API calls made to SQS are
9
+ # called via methods on this class.
10
+ #
11
+ # === Example
12
+ #
13
+ # client = Bisques::Client.new('us-east-1', 'my_queues_', AwsCredentials.new(aws_key, aws_secret))
14
+ # client.list_queues
15
+ #
16
+ class Client
17
+ # The queue prefix when interacting with SQS. The client will only be able
18
+ # to see queues whose name has this prefix.
19
+ attr_accessor :queue_prefix
20
+
21
+ include AwsConnection
22
+
23
+ # Initialize a client object. The AWS region must be specified. For
24
+ # example, 'us-east-1'. An optional queue prefix can be provided to
25
+ # restrict the queues this client can see and interact with. AWS
26
+ # credentials must be provided, or defaults set in AwsCredentials.
27
+ def initialize(region, queue_prefix = nil, credentials = AwsCredentials.default)
28
+ super(region, "sqs", credentials)
29
+ @queue_prefix = queue_prefix
30
+ end
31
+
32
+ # Returns a Queue object representing an SQS queue, creating it if it does
33
+ # not already exist.
34
+ def get_or_create_queue(name)
35
+ get_queue(name) || create_queue(name, {})
36
+ end
37
+
38
+ # Creates a new SQS queue and returns a Queue object.
39
+ def create_queue(name, attributes = {})
40
+ response = action("CreateQueue", {"QueueName" => Queue.sanitize_name("#{queue_prefix}#{name}")}.merge(attributes))
41
+
42
+ if response.success?
43
+ Queue.new(self, response.doc.xpath("//QueueUrl").text)
44
+ else
45
+ raise "Could not create queue #{name}"
46
+ end
47
+ end
48
+
49
+ # Deletes an SQS queue at a given path.
50
+ def delete_queue(queue_url)
51
+ response = action("DeleteQueue", queue_url)
52
+ end
53
+
54
+ # Get an SQS queue by name. Returns a Queue object if the queue is found, otherwise nil.
55
+ def get_queue(name, options = {})
56
+ response = action("GetQueueUrl", {"QueueName" => Queue.sanitize_name("#{queue_prefix}#{name}")}.merge(options))
57
+
58
+ if response.success?
59
+ Queue.new(self, response.doc.xpath("//QueueUrl").text)
60
+ end
61
+
62
+ rescue Bisques::AwsActionError => e
63
+ raise unless e.code == "AWS.SimpleQueueService.NonExistentQueue"
64
+ end
65
+
66
+ # Return an array of Queue objects representing the queues found in SQS. An
67
+ # optional prefix can be supplied to restrict the queues found. This prefix
68
+ # is additional to the client prefix.
69
+ #
70
+ # Example:
71
+ #
72
+ # # Delete all the queues
73
+ # client.list_queues.each do |queue|
74
+ # queue.delete
75
+ # end
76
+ #
77
+ def list_queues(prefix = "")
78
+ response = action("ListQueues", "QueueNamePrefix" => "#{queue_prefix}#{prefix}")
79
+ response.doc.xpath("//ListQueuesResult/QueueUrl").map(&:text).map do |url|
80
+ Queue.new(self, url)
81
+ end
82
+ end
83
+
84
+ # Get the attributes for a queue. Takes an array of attribute names.
85
+ # Defaults to ["All"] which returns all the available attributes.
86
+ #
87
+ # This returns an AwsResponse object.
88
+ def get_queue_attributes(queue_url, attributes = ["All"])
89
+ attributes = attributes.map(&:to_s)
90
+
91
+ query = Hash[*attributes.each_with_index.map do |attribute, index|
92
+ ["AttributeName.#{index+1}", attribute]
93
+ end.flatten]
94
+
95
+ action("GetQueueAttributes", queue_url, query)
96
+ end
97
+
98
+ # Put a message on a queue. Takes the queue url and the message body, which
99
+ # should be a string. An optional delay seconds argument can be added if
100
+ # the message should not become visible immediately.
101
+ #
102
+ # Example:
103
+ #
104
+ # client.send_message(queue.path, "test message")
105
+ #
106
+ def send_message(queue_url, message_body, delay_seconds=nil)
107
+ options = {"MessageBody" => message_body}
108
+ options["DelaySeconds"] = delay_seconds if delay_seconds
109
+
110
+ tries = 0
111
+ md5 = Digest::MD5.hexdigest(message_body)
112
+
113
+ begin
114
+ tries += 1
115
+ response = action("SendMessage", queue_url, options)
116
+
117
+ returned_md5 = response.doc.xpath("//MD5OfMessageBody").text
118
+ raise MessageHasWrongMd5Error.new(message_body, md5, returned_md5) unless md5 == returned_md5
119
+ rescue MessageHasWrongMd5Error
120
+ if tries < 2
121
+ retry
122
+ else
123
+ raise
124
+ end
125
+ end
126
+ end
127
+
128
+ # Delete a message from a queue. The message is deleted by the handle given
129
+ # when the message is retrieved.
130
+ def delete_message(queue_url, receipt_handle)
131
+ response = action("DeleteMessage", queue_url, {"ReceiptHandle" => receipt_handle})
132
+ end
133
+
134
+ # Receive a message from a queue. Takes the queue url and an optional hash.
135
+ def receive_message(queue_url, options = {})
136
+ # validate_options(options, %w(AttributeName MaxNumberOfMessages VisibilityTimeout WaitTimeSeconds))
137
+ action("ReceiveMessage", queue_url, options)
138
+ end
139
+
140
+ # Change the visibility of a message on the queue. This is useful if you
141
+ # have retrieved a message and now want to keep it hidden for longer before
142
+ # deleting it, or if you have a job and decide you cannot action it and
143
+ # want to return it to the queue sooner.
144
+ def change_message_visibility(queue_url, receipt_handle, visibility_timeout)
145
+ action("ChangeMessageVisibility", queue_url, {"ReceiptHandle" => receipt_handle, "VisibilityTimeout" => visibility_timeout})
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,47 @@
1
+ require 'json'
2
+
3
+ module Bisques
4
+ # A message received from an SQS queue.
5
+ class Message
6
+ # The queue this message originated from.
7
+ attr_reader :queue
8
+ # The AWS Id of the message.
9
+ attr_reader :id
10
+ # A unique handle used to manipulate the message.
11
+ attr_reader :handle
12
+ # The raw text body of the message.
13
+ attr_reader :body
14
+ # Hash of SQS attributes.
15
+ attr_reader :attributes
16
+
17
+ def initialize(queue, id, handle, body, attributes = {}) #:nodoc:
18
+ @queue, @id, @handle, @body, @attributes = queue, id, handle, body, attributes
19
+ end
20
+
21
+ # The deserialized object in the message. This method is used to retrieve
22
+ # the contents that Queue#post_message placed there.
23
+ #
24
+ # Example:
25
+ #
26
+ # queue.post_message([1,2,3])
27
+ # queue.retrieve.object => [1,2,3]
28
+ #
29
+ def object
30
+ @object ||= JSON.parse(body)
31
+ end
32
+
33
+ # Delete the message from the queue. This should be called after the
34
+ # message has been received and processed. If not then after a timeout the
35
+ # message will get added back to the queue.
36
+ def delete
37
+ queue.delete_message(handle)
38
+ end
39
+
40
+ # Return the message to the queue immediately. If a client has taken a
41
+ # message and cannot process it for any reason it can put the message back
42
+ # faster than the default timeout by calling this method.
43
+ def return
44
+ queue.return_message(handle)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,148 @@
1
+ require 'bisques/message'
2
+
3
+ module Bisques
4
+ # An SQS queue
5
+ class Queue
6
+ class QueueError < Bisques::Error
7
+ def initialize(queue, message)
8
+ @queue = queue
9
+ super("queue: #{queue.name}; #{message}")
10
+ end
11
+ end
12
+ class QueueNotFound < QueueError; end
13
+
14
+
15
+ attr_reader :client # :nodoc:
16
+
17
+ def self.sanitize_name(name)
18
+ name = name.gsub(/[^_\w\d]/, "")
19
+
20
+ if name.length > 80
21
+ short_name = name[0,75]
22
+ short_name << Digest::MD5.hexdigest(name)
23
+ short_name = short_name[0,80]
24
+ name = short_name
25
+ end
26
+
27
+ name
28
+ end
29
+
30
+ # Queues are created by the Client passing the client itself and the url
31
+ # for the queue.
32
+ def initialize(client, url)
33
+ @client, @url = client, url
34
+ end
35
+
36
+ # The name of the queue derived from the URL.
37
+ def name
38
+ @url.split("/").last
39
+ end
40
+
41
+ # The path part of the queue URL
42
+ def path
43
+ Addressable::URI.parse(@url).path
44
+ end
45
+ alias_method :url, :path
46
+
47
+ def eql?(queue)
48
+ hash == queue.hash
49
+ end
50
+ def ==(queue)
51
+ hash == queue.hash
52
+ end
53
+ def hash # :nodoc:
54
+ @url.hash
55
+ end
56
+
57
+ # Return attributes for the queue. Pass in the names of the attributes to
58
+ # retrieve, or :All for all attributes. The available attributes can be
59
+ # found at
60
+ # http://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/Query_QueryGetQueueAttributes.html
61
+ #
62
+ # If 1 attribute is requested then just that attributes value is returned.
63
+ # If more than one, or all, attributes are requested then a hash of
64
+ # attribute names and values is returned.
65
+ #
66
+ # ==== Example with one attribute:
67
+ #
68
+ # queue.attributes(:ApproximateNumberOfMessages) => 10
69
+ #
70
+ # ==== Example with multiple attributes:
71
+ #
72
+ # queue.attributes(:ApproximateNumberOfMessages, :ApproximateNumberOfMessagesDelayed) => {:ApproximateNumberOfMessages => 10, :ApproximateNumberOfMessagesDelayed => 5}
73
+ #
74
+ def attributes(*attributes)
75
+ return nil if attributes.blank?
76
+
77
+ values = {}
78
+ response = client.get_queue_attributes(url, attributes)
79
+
80
+ response.doc.xpath("//Attribute").each do |attribute_element|
81
+ name = attribute_element.xpath("Name").text
82
+ value = attribute_element.xpath("Value").text
83
+ value = value.to_i if value =~ /\A\d+\z/
84
+ values[name] = value
85
+ end
86
+
87
+ if values.size == 1 && attributes.size == 1
88
+ values.values.first
89
+ else
90
+ values
91
+ end
92
+ end
93
+
94
+ # Delete the queue
95
+ def delete
96
+ client.delete_queue(url)
97
+ end
98
+
99
+ # Post a message to the queue. The message must be serializable (i.e.
100
+ # strings, numbers, arrays, hashes).
101
+ def post_message(object)
102
+ client.send_message(url, JSON.dump(object))
103
+ end
104
+
105
+ # Retrieve a message from the queue. Returns nil if no message is waiting
106
+ # in the given poll time. Otherwise it returns a Message.
107
+ def retrieve(poll_time = 1)
108
+ response = client.receive_message(url, {"WaitTimeSeconds" => poll_time, "MaxNumberOfMessages" => 1})
109
+ raise QueueNotFound.new(self, "not found at #{url}") if response.http_response.status == 404
110
+
111
+ response.doc.xpath("//Message").map do |element|
112
+ attributes = Hash[*element.xpath("Attribute").map do |attr_element|
113
+ [attr_element.xpath("Name").text, attr_element.xpath("Value").text]
114
+ end.flatten]
115
+
116
+ Message.new(self, element.xpath("MessageId").text,
117
+ element.xpath("ReceiptHandle").text,
118
+ element.xpath("Body").text,
119
+ attributes
120
+ )
121
+ end.first
122
+ end
123
+
124
+ # Retrieve a single message from the queue. This will block until a message
125
+ # arrives. The message will be of the class Message.
126
+ def retrieve_one(poll_time = 5)
127
+ object = nil
128
+ while object.nil?
129
+ object = retrieve(poll_time)
130
+ end
131
+ object
132
+ end
133
+
134
+ # Delete a message from the queue. This should be called to confirm that
135
+ # the message has been processed. If it is not called then the message will
136
+ # get put back on the queue after a timeout.
137
+ def delete_message(handle)
138
+ response = client.delete_message(url, handle)
139
+ response.success?
140
+ end
141
+
142
+ # Return a message to the queue after receiving it. This would typically
143
+ # happen if the receiver decided it couldn't process.
144
+ def return_message(handle)
145
+ client.change_message_visibility(url, handle, 0)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,89 @@
1
+ require 'bisques/queue'
2
+ require 'thread'
3
+
4
+ module Bisques
5
+ # Listen for messages on a queue and execute a block when they arrive.
6
+ class QueueListener
7
+ def initialize(queue, poll_time = 5)
8
+ @queue, @poll_time = queue, poll_time
9
+ end
10
+
11
+ def listening?
12
+ @listening
13
+ end
14
+
15
+ # Listen for messages. This is asynchronous and returns immediately.
16
+ #
17
+ # Ex:
18
+ #
19
+ # queue = bisques.find_or_create_queue("my queue")
20
+ # listener = QueuedListener.new(queue)
21
+ # listener.listen do |message|
22
+ # puts "Received #{message.object}"
23
+ # message.delete
24
+ # end
25
+ #
26
+ # while true; sleep 1; end # Process messages forever
27
+ #
28
+ # Note that the block you give to this method is executed in a new thread.
29
+ #
30
+ def listen(&block)
31
+ return if @listening
32
+ @listening = true
33
+
34
+ @thread = Thread.new do
35
+ while @listening
36
+ message = @queue.retrieve(@poll_time)
37
+ block.call(message) if message.present?
38
+ end
39
+ end
40
+ end
41
+
42
+ # Stop listening for messages
43
+ def stop
44
+ @listening = false
45
+ @thread.join if @thread
46
+ end
47
+ end
48
+
49
+ # Listen for messages on several queues at the same time. The interface for
50
+ # objects of this class is identical to that of QueueListener.
51
+ #
52
+ # Ex:
53
+ #
54
+ # queue_1 = bisques.find_or_create_queue("queue one")
55
+ # queue_2 = bisques.find_or_create_queue("queue two")
56
+ # listener = MultiQueueListener.new(queue_1, queue_2)
57
+ # listener.listen do |message|
58
+ # puts "Queue #{message.queue.name}, message #{message.object}"
59
+ # message.delete
60
+ # end
61
+ # while true; sleep 1; end # Process messages forever
62
+ #
63
+ class MultiQueueListener
64
+ def initialize(*queues)
65
+ @queues = queues
66
+ @listeners = []
67
+ end
68
+
69
+ def listening?
70
+ @listeners.any?
71
+ end
72
+
73
+ def listen(&block)
74
+ return if @listeners.any?
75
+ @listeners = @queues.map do |queue|
76
+ QueueListener.new(queue)
77
+ end
78
+
79
+ @listeners.each do |listener|
80
+ listener.listen(&block)
81
+ end
82
+ end
83
+
84
+ def stop
85
+ @listeners.each(&:stop)
86
+ @listeners = []
87
+ end
88
+ end
89
+ end
data/lib/bisques.rb ADDED
@@ -0,0 +1,14 @@
1
+ # See README.rdoc
2
+ module Bisques
3
+ class Error < StandardError; end
4
+ class MessageHasWrongMd5Error < Error
5
+ attr_reader :msg, :expected, :got
6
+
7
+ def initialize(msg, expected, got)
8
+ @msg, @expected, @got = msg, expected, got
9
+ super(msg)
10
+ end
11
+ end
12
+ end
13
+
14
+ require 'bisques/client'
metadata ADDED
@@ -0,0 +1,139 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bisques
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Jeremy Wells
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-12-31 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: httpclient
16
+ prerelease: false
17
+ requirement: !ruby/object:Gem::Requirement
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ none: false
23
+ type: :runtime
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ! '>='
27
+ - !ruby/object:Gem::Version
28
+ version: '0'
29
+ none: false
30
+ - !ruby/object:Gem::Dependency
31
+ name: addressable
32
+ prerelease: false
33
+ requirement: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ none: false
39
+ type: :runtime
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ! '>='
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ none: false
46
+ - !ruby/object:Gem::Dependency
47
+ name: nokogiri
48
+ prerelease: false
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ none: false
55
+ type: :runtime
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ! '>='
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ none: false
62
+ - !ruby/object:Gem::Dependency
63
+ name: json
64
+ prerelease: false
65
+ requirement: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ none: false
71
+ type: :runtime
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ! '>='
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ none: false
78
+ - !ruby/object:Gem::Dependency
79
+ name: rspec
80
+ prerelease: false
81
+ requirement: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ none: false
87
+ type: :development
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ! '>='
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ none: false
94
+ description: AWS SQS client
95
+ email: jemmyw@gmail.com
96
+ executables: []
97
+ extensions: []
98
+ extra_rdoc_files:
99
+ - README.rdoc
100
+ files:
101
+ - README.rdoc
102
+ - Rakefile
103
+ - lib/bisques/aws_connection.rb
104
+ - lib/bisques/aws_credentials.rb
105
+ - lib/bisques/aws_request.rb
106
+ - lib/bisques/aws_request_authorization.rb
107
+ - lib/bisques/aws_response.rb
108
+ - lib/bisques/client.rb
109
+ - lib/bisques/message.rb
110
+ - lib/bisques/queue.rb
111
+ - lib/bisques/queue_listener.rb
112
+ - lib/bisques.rb
113
+ homepage: https://github.com/bigears/bisques
114
+ licenses: []
115
+ post_install_message:
116
+ rdoc_options:
117
+ - --line-numbers
118
+ - --inline-source
119
+ require_paths:
120
+ - lib
121
+ required_ruby_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ none: false
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ! '>='
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ none: false
133
+ requirements: []
134
+ rubyforge_project:
135
+ rubygems_version: 1.8.24
136
+ signing_key:
137
+ specification_version: 3
138
+ summary: AWS SQS client
139
+ test_files: []