bisques 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/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: []