├── .rspec ├── Rakefile ├── lib ├── lapine │ ├── consumer.rb │ ├── version.rb │ ├── errors.rb │ ├── consumer │ │ ├── middleware │ │ │ ├── message_ack_handler.rb │ │ │ ├── json_decoder.rb │ │ │ └── error_handler.rb │ │ ├── connection.rb │ │ ├── message.rb │ │ ├── middleware.rb │ │ ├── topology.rb │ │ ├── dispatcher.rb │ │ ├── config.rb │ │ └── runner.rb │ ├── test │ │ ├── rspec_helper.rb │ │ └── exchange.rb │ ├── cli.rb │ ├── publisher.rb │ ├── dtrace.rb │ ├── exchange.rb │ ├── configuration.rb │ └── annotated_logger.rb └── lapine.rb ├── bin └── lapine ├── example ├── consumer_handler.rb ├── producer.rb └── consumer_config.yml ├── Gemfile ├── .gitignore ├── Guardfile ├── spec ├── support │ └── rspec_test_helper.rb ├── lib │ ├── lapine │ │ ├── consumer │ │ │ ├── connection_spec.rb │ │ │ ├── dispatcher_spec.rb │ │ │ ├── runner_spec.rb │ │ │ ├── topology_spec.rb │ │ │ ├── middleware_spec.rb │ │ │ └── config_spec.rb │ │ ├── test │ │ │ └── exchange_spec.rb │ │ └── publisher_spec.rb │ └── lapine_spec.rb └── spec_helper.rb ├── LICENSE.txt ├── lapine.gemspec ├── Changelog.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | -------------------------------------------------------------------------------- /lib/lapine/consumer.rb: -------------------------------------------------------------------------------- 1 | require 'lapine/consumer/runner' 2 | -------------------------------------------------------------------------------- /lib/lapine/version.rb: -------------------------------------------------------------------------------- 1 | module Lapine 2 | VERSION = '2.0.3' 3 | end 4 | -------------------------------------------------------------------------------- /bin/lapine: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'lapine/cli' 4 | Lapine::CLI.new(ARGV).run 5 | 6 | -------------------------------------------------------------------------------- /example/consumer_handler.rb: -------------------------------------------------------------------------------- 1 | class ConsumerHandler 2 | def self.handle_lapine_payload(hash, metadata) 3 | puts hash, payload 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in lapine.gemspec 4 | gemspec 5 | 6 | group :test do 7 | gem 'pry-nav' 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | 2 | guard :rspec, cmd: 'bundle exec rspec' do 3 | watch(%r{^spec/.+_spec\.rb$}) 4 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 5 | watch('spec/spec_helper.rb') { "spec" } 6 | end 7 | 8 | -------------------------------------------------------------------------------- /example/producer.rb: -------------------------------------------------------------------------------- 1 | require 'lapine' 2 | 3 | class Producer 4 | include Lapine::Publisher 5 | 6 | exchange 'lapine.topic' 7 | 8 | attr_reader :id 9 | 10 | def initialize(id) 11 | @id = id 12 | end 13 | 14 | def to_hash 15 | { 16 | data: id 17 | } 18 | end 19 | end 20 | 21 | -------------------------------------------------------------------------------- /lib/lapine/errors.rb: -------------------------------------------------------------------------------- 1 | module Lapine 2 | class LapineError < StandardError; end 3 | class UndefinedConnection < LapineError; end 4 | class UndefinedExchange < LapineError; end 5 | class NilExchange < LapineError; end 6 | 7 | class MiddlewareNotFound < LapineError; end 8 | class DuplicateMiddleware < LapineError; end 9 | end 10 | -------------------------------------------------------------------------------- /example/consumer_config.yml: -------------------------------------------------------------------------------- 1 | connection: 2 | host: '127.0.0.1' 3 | port: 5672 4 | ssl: false 5 | vhost: '/' 6 | username: 'guest' 7 | password: 'guest' 8 | 9 | require: 10 | - example/consumer_handler 11 | 12 | topics: 13 | - lapine.topic 14 | 15 | queues: 16 | - q: handler 17 | topic: lapine.topic 18 | routing_key: stuff 19 | handlers: 20 | - ConsumerHandler 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /spec/support/rspec_test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'lapine/test/rspec_helper' 2 | 3 | RSpec.configure do |config| 4 | config.include Lapine::Test::RSpecHelper, with_rspec_helper: true 5 | 6 | config.before :each, :with_rspec_helper do |example| 7 | Lapine::Test::RSpecHelper.setup(example) 8 | end 9 | 10 | config.after :each, :with_rspec_helper do 11 | Lapine::Test::RSpecHelper.teardown 12 | end 13 | end 14 | 15 | -------------------------------------------------------------------------------- /lib/lapine/consumer/middleware/message_ack_handler.rb: -------------------------------------------------------------------------------- 1 | module Lapine 2 | module Consumer 3 | module Middleware 4 | class MessageAckHandler 5 | attr_reader :app 6 | 7 | def initialize(app) 8 | @app = app 9 | end 10 | 11 | def call(message) 12 | app.call(message) 13 | ensure 14 | message.metadata.ack 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/lapine/consumer/middleware/json_decoder.rb: -------------------------------------------------------------------------------- 1 | require 'oj' 2 | 3 | module Lapine 4 | module Consumer 5 | module Middleware 6 | class JsonDecoder 7 | attr_reader :app 8 | 9 | def initialize(app) 10 | @app = app 11 | end 12 | 13 | def call(message) 14 | message['decoded_payload'] = Oj.load(message.payload) 15 | app.call(message) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/lapine/consumer/middleware/error_handler.rb: -------------------------------------------------------------------------------- 1 | module Lapine 2 | module Consumer 3 | module Middleware 4 | class ErrorHandler 5 | attr_reader :app 6 | 7 | def initialize(app) 8 | @app = app 9 | end 10 | 11 | def call(message) 12 | app.call(message) 13 | rescue StandardError => e 14 | Lapine::Consumer::Dispatcher.error_handler.call(e, message.payload, message.metadata) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/lapine/test/rspec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'lapine/test/exchange' 2 | 3 | module Lapine 4 | module Test 5 | module RSpecHelper 6 | def self.setup(_example = nil) 7 | RSpec::Mocks::AllowanceTarget.new(Lapine::Exchange).to( 8 | RSpec::Mocks::Matchers::Receive.new(:new, ->(name, properties) { 9 | Lapine::Test::Exchange.new(name, properties) 10 | }) 11 | ) 12 | end 13 | 14 | def self.teardown 15 | Lapine.close_connections! 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/lapine/consumer/connection.rb: -------------------------------------------------------------------------------- 1 | require 'amqp' 2 | require 'eventmachine' 3 | 4 | module Lapine 5 | module Consumer 6 | class Connection 7 | attr_reader :connection, :channel, :exchange 8 | 9 | def initialize(config, topic) 10 | @connection = AMQP.connect(config.connection_properties) 11 | @channel = AMQP::Channel.new(connection) 12 | @exchange = AMQP::Exchange.new(channel, :topic, topic, durable: true) 13 | end 14 | 15 | def close! 16 | @connection.close if @connection.connected? 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/lapine/cli.rb: -------------------------------------------------------------------------------- 1 | module Lapine 2 | class CLI 3 | attr_reader :argv, :command 4 | 5 | def initialize(argv) 6 | @argv = argv 7 | @command = argv.shift 8 | end 9 | 10 | def run 11 | case command 12 | when 'consume' 13 | require 'lapine/consumer' 14 | ::Lapine::Consumer::Runner.new(argv).run 15 | else 16 | usage 17 | end 18 | end 19 | 20 | def usage 21 | puts <<-EOF.gsub(/^ {8}/, '') 22 | Usage: lapine [command] [options] 23 | 24 | commands: consume 25 | EOF 26 | exit 1 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/lapine/publisher.rb: -------------------------------------------------------------------------------- 1 | require 'oj' 2 | 3 | module Lapine 4 | module Publisher 5 | 6 | def self.included(klass) 7 | klass.send :extend, ClassMethods 8 | end 9 | 10 | def publish(routing_key = nil) 11 | Lapine.find_exchange(self.class.current_lapine_exchange).publish(to_json, routing_key: routing_key) 12 | end 13 | 14 | def to_json 15 | ::Oj.dump(to_hash, mode: :compat) 16 | end 17 | 18 | module ClassMethods 19 | def exchange(name) 20 | @lapine_exchange = name 21 | end 22 | 23 | def current_lapine_exchange 24 | @lapine_exchange 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/lapine/consumer/message.rb: -------------------------------------------------------------------------------- 1 | module Lapine 2 | module Consumer 3 | class Message < Hash 4 | def initialize(payload, metadata, logger) 5 | super(nil) 6 | self['payload'] = payload 7 | self['metadata'] = metadata 8 | self['logger'] = logger 9 | end 10 | 11 | def payload 12 | self['payload'] 13 | end 14 | 15 | def decoded_payload 16 | self['decoded_payload'] 17 | end 18 | 19 | def metadata 20 | self['metadata'] 21 | end 22 | 23 | def logger 24 | self['logger'] 25 | end 26 | 27 | def routing_key 28 | metadata.routing_key 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/lapine/dtrace.rb: -------------------------------------------------------------------------------- 1 | require 'usdt' 2 | 3 | module Lapine 4 | class DTrace 5 | attr_reader :provider, :probes 6 | 7 | def initialize 8 | @provider = USDT::Provider.create(:ruby, :lapine) 9 | 10 | @probes = { 11 | # args: Class name, payload 12 | dispatch_enter: provider.probe(:dispatch, :enter, :string, :string), 13 | # args: Class name, payload 14 | dispatch_return: provider.probe(:dispatch, :return, :string, :string), 15 | } 16 | end 17 | 18 | def self.provider 19 | @provider ||= new.tap do |p| 20 | p.provider.enable 21 | end 22 | end 23 | 24 | def self.fire!(probe_name, *args) 25 | raise ArgumentError.new("Unknown probe: #{probe_name}") unless self.provider.probes[probe_name] 26 | probe = self.provider.probes[probe_name] 27 | probe.fire(*args) if probe.enabled? 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/lib/lapine/consumer/connection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'lapine/consumer/connection' 3 | 4 | RSpec.describe Lapine::Consumer::Connection do 5 | 6 | describe "initialize" do 7 | let(:properties) { {host: '127.0.0.1', port: 5672, ssl: false, vhost: '/', username: 'guest', password: 'guest'} } 8 | let(:connection) { double('AMQP::Session') } 9 | let(:channel) { double('AMQP::Channel') } 10 | let(:config) { double('config', connection_properties: properties) } 11 | 12 | before do 13 | expect(AMQP).to receive(:connect).with(properties) { connection } 14 | expect(AMQP::Channel).to receive(:new).with(connection) { channel } 15 | end 16 | 17 | it "Builds amqp objects" do 18 | expect(AMQP::Exchange).to receive(:new).with(channel, :topic, 'thing.topic', durable: true) 19 | described_class.new(config, 'thing.topic') 20 | end 21 | end 22 | end 23 | 24 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'lapine' 2 | require 'pry' 3 | require 'rspec/mocks' 4 | require 'lapine/consumer/dispatcher' 5 | require 'lapine/consumer/middleware' 6 | 7 | Dir[File.expand_path('../support/**/*.rb', __FILE__)].each do |f| 8 | require f 9 | end 10 | 11 | RSpec.configure do |config| 12 | config.expect_with :rspec do |expectations| 13 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 14 | end 15 | 16 | config.mock_with :rspec do |mocks| 17 | mocks.verify_partial_doubles = true 18 | end 19 | 20 | config.disable_monkey_patching! 21 | config.order = :random # use --seed NNNN 22 | Kernel.srand config.seed 23 | 24 | config.before :each do 25 | Lapine::Consumer::Dispatcher.error_handler = nil 26 | Lapine.instance_variable_set(:@config, nil) 27 | Lapine::Consumer::Middleware.instance_variable_set(:@registry, nil) 28 | Thread.current.thread_variable_set(:lapine_exchanges, nil) 29 | end 30 | 31 | config.after :each do 32 | Lapine.close_connections! 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/lib/lapine/test/exchange_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'lapine/test/rspec_helper' 3 | 4 | RSpec.describe Lapine::Test::Exchange, with_rspec_helper: true do 5 | class Publisher 6 | include Lapine::Publisher 7 | 8 | exchange 'my.topic' 9 | 10 | def to_hash 11 | { 12 | omg: 'lol' 13 | } 14 | end 15 | end 16 | 17 | let(:exchange) { Lapine.find_exchange('my.topic') } 18 | let(:queue) { exchange.channel.queue.bind(exchange) } 19 | 20 | before do 21 | Lapine.add_connection 'conn', {} 22 | Lapine.add_exchange 'my.topic', connection: 'conn' 23 | queue 24 | end 25 | 26 | describe 'publish' do 27 | it 'changes the queue message count' do 28 | expect { 29 | Publisher.new.publish 30 | }.to change { 31 | queue.message_count 32 | }.to(1) 33 | end 34 | 35 | it 'saves message for later introspection' do 36 | Publisher.new.publish('my.things') 37 | message = ['{"omg":"lol"}', {routing_key: 'my.things'}] 38 | expect(queue.messages).to include(message) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Eric Saxby & Matt Camuto 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/lapine/exchange.rb: -------------------------------------------------------------------------------- 1 | require 'bunny' 2 | 3 | module Lapine 4 | class Exchange 5 | attr_reader :conn, :name, :props, :connection_name, :exchange_type 6 | 7 | def initialize(name, properties) 8 | @name = name 9 | @props = properties.dup 10 | @connection_name = props.delete(:connection) 11 | @exchange_type = props.delete(:type) 12 | ObjectSpace.define_finalizer(self, proc { |id| Lapine.config.cleanup_exchange(id) }) 13 | end 14 | 15 | def connected? 16 | @exchange && 17 | @exchange.channel && 18 | @exchange.channel.connection && 19 | @exchange.channel.connection.connected? && 20 | @exchange.channel.open? 21 | end 22 | 23 | def exchange 24 | @exchange ||= begin 25 | conn = Lapine.config.active_connection(connection_name) 26 | conn.logger.info "Creating channel for #{self.object_id}, thread: #{Thread.current.object_id}" 27 | channel = conn.create_channel 28 | Lapine.config.register_channel(self.object_id, channel) 29 | Bunny::Exchange.new(channel, exchange_type, name, props) 30 | end 31 | end 32 | 33 | def close 34 | @exchange.channel.close 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/lib/lapine/publisher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'lapine/publisher' 3 | 4 | RSpec.describe Lapine::Publisher do 5 | subject(:publisher) { publisher_class.new } 6 | 7 | let(:publisher_class) { 8 | Class.new.tap do |klass| 9 | klass.send :include, Lapine::Publisher 10 | klass.send :exchange, exchange 11 | klass.send :define_method, :to_hash do 12 | {} 13 | end 14 | end 15 | } 16 | let(:exchange) { 'test_exchange' } 17 | let(:fake_exchange) { double } 18 | 19 | before do 20 | allow(Lapine).to receive(:find_exchange).with(exchange).and_return(fake_exchange) 21 | end 22 | 23 | describe '#to_json' do 24 | before do 25 | allow(publisher).to receive(:to_hash).and_return({foo: 'bar'}) 26 | end 27 | 28 | it 'turns the output of #to_hash into JSON' do 29 | expect(publisher.to_json).to eq('{"foo":"bar"}') 30 | end 31 | end 32 | 33 | describe '#publish' do 34 | let(:json) { '{"foo": "bar"}' } 35 | 36 | it 'publishes data with routing key' do 37 | expect(publisher).to receive(:to_json).and_return(json) 38 | expect(fake_exchange).to receive(:publish).with(json, routing_key: 'thing.stuff') 39 | publisher.publish('thing.stuff') 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/lapine/consumer/middleware.rb: -------------------------------------------------------------------------------- 1 | require 'lapine/consumer/middleware/error_handler' 2 | require 'lapine/consumer/middleware/message_ack_handler' 3 | require 'lapine/consumer/middleware/json_decoder' 4 | require 'middlewear' 5 | 6 | module Lapine 7 | module Consumer 8 | # 9 | # Consumer middleware can be registered as follows: 10 | # 11 | # Lapine::Consumer::Middleware.add MyClass 12 | # Lapine::Consumer::Middleware.add MyClass, argument 13 | # Lapine::Consumer::Middleware.add_before MyClass, MyOtherClass, argument 14 | # Lapine::Consumer::Middleware.add_after MyClass, MyOtherClass, argument 15 | # 16 | # Middleware should follow the pattern: 17 | # 18 | # class MyMiddleware 19 | # attr_reader :app 20 | # 21 | # def initialize(app, *arguments) 22 | # @app = app 23 | # end 24 | # 25 | # def call(message) 26 | # # do stuff 27 | # app.call(message) 28 | # end 29 | # end 30 | # 31 | module Middleware 32 | include Middlewear 33 | 34 | DEFAULT_MIDDLEWARE = [ 35 | MessageAckHandler, 36 | ErrorHandler, 37 | JsonDecoder 38 | ].freeze 39 | 40 | DEFAULT_MIDDLEWARE.each do |middleware| 41 | Lapine::Consumer::Middleware.add(middleware) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/lapine.rb: -------------------------------------------------------------------------------- 1 | require 'lapine/version' 2 | require 'lapine/errors' 3 | require 'lapine/configuration' 4 | require 'lapine/exchange' 5 | require 'lapine/publisher' 6 | require 'lapine/annotated_logger' 7 | 8 | module Lapine 9 | def self.config 10 | @config ||= Configuration.new 11 | end 12 | 13 | def self.add_connection(name, properties) 14 | config.connection_properties[name] = properties 15 | end 16 | 17 | def self.add_exchange(name, properties) 18 | connection = properties[:connection] 19 | raise UndefinedConnection.new("No connection for #{name}, properties: #{properties}") unless connection 20 | raise UndefinedConnection.new("No connection properties for #{name}, properties: #{properties}") unless config.connection_properties[connection] 21 | config.exchange_properties[name] = properties 22 | end 23 | 24 | def self.find_exchange(name) 25 | exchange = config.exchanges[name] 26 | return exchange.exchange if (exchange && exchange.connected?) 27 | 28 | exchange_configuration = config.exchange_properties[name] 29 | raise UndefinedExchange.new("No exchange configuration for #{name}") unless exchange_configuration 30 | 31 | config.exchanges[name] = Lapine::Exchange.new(name, exchange_configuration) 32 | config.exchanges[name].exchange 33 | end 34 | 35 | def self.close_connections! 36 | config.close_connections! 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lapine.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'lapine/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'lapine' 8 | spec.version = Lapine::VERSION 9 | spec.authors = ['Eric Saxby', 'Matt Camuto'] 10 | spec.email = ['dev@wanelo.com'] 11 | spec.summary = %q{Talk to rabbits} 12 | spec.description = %q{Talk to rabbits} 13 | spec.homepage = 'https://github.com/messagebus/lapine' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_dependency 'amqp' 22 | spec.add_dependency 'bunny' 23 | spec.add_dependency 'environmenter', '~> 0.1' 24 | spec.add_dependency 'middlewear', '~> 0.1' 25 | spec.add_dependency 'mixlib-cli' 26 | spec.add_dependency 'oj' 27 | spec.add_dependency 'ruby-usdt', '>= 0.2.2' 28 | 29 | spec.add_development_dependency 'bundler', '~> 2.1' 30 | spec.add_development_dependency 'guard-rspec', '~> 4.3' 31 | spec.add_development_dependency 'rake', '~> 13.0' 32 | spec.add_development_dependency 'rspec', '~> 3.1' 33 | spec.add_development_dependency 'em-spec' 34 | end 35 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | ## 2.0.0 5 | 6 | **BREAKING CHANGES** 7 | 8 | Queues are now declared as `durable: true`. This breaks the ability of consumers to connect to existing 9 | queues in a way that fails silently in the `ruby-amqp` gem. 10 | 11 | Migration strategy: 12 | 13 | * Update gemfile to use version `1.99.0` 14 | * All queues will need to be renamed, so that they can be declared anew with `durable: true` 15 | * Old queues should be deleted with `delete_queues` 16 | * Update gemfile to use version `2.0.0` 17 | 18 | ## 1.99.0 - Migration version to 2.0.0 19 | 20 | **BREAKING CHANGES** 21 | 22 | * `queues` are declared as `durable: true` 23 | * `delete_queues` are declared as `durable: false` 24 | 25 | ## 1.2.2 26 | 27 | * Add routing key to dispatcher log 28 | 29 | ## 1.2.0 30 | 31 | * Queues can be deleted by using `delete_queues` to configuration YAML file 32 | 33 | ## 1.1.2 34 | 35 | * Exchanges are saved using thread variables instead of fiber variables 36 | * Move memoization of connections and exchanges to Configuration 37 | 38 | ## 1.1.1 39 | 40 | * Fix potential thread safety issue with publisher connections to 41 | RabbitMQ 42 | 43 | ## 1.1.0 44 | 45 | * Lapine consumer can be configured with middleware 46 | * Error handling, json decoding, and message acknowledgement now happen in middleware 47 | 48 | ## 1.0.1 49 | 50 | * Increased verbosity of errors 51 | * Avoid instance_variable_get in publisher 52 | * rename @exchange to @lapine_exchange 53 | 54 | ## 1.0.0 55 | 56 | * Breaking Change - error handler includes metadata in method signature 57 | -------------------------------------------------------------------------------- /lib/lapine/consumer/topology.rb: -------------------------------------------------------------------------------- 1 | require 'lapine/consumer/connection' 2 | 3 | module Lapine 4 | module Consumer 5 | class Topology < Struct.new(:config, :logger) 6 | 7 | def each_binding 8 | config.queues.each do |node| 9 | classes = classify(node['handlers']) 10 | yield node['q'], get_conn(node['topic']), node['routing_key'], classes 11 | end 12 | end 13 | 14 | def each_queue_to_delete 15 | config.delete_queues.each do |node| 16 | classes = classify(node['handlers']) 17 | yield node['q'], get_conn(node['topic']), node['routing_key'], classes 18 | end 19 | end 20 | 21 | def each_topic 22 | config.topics.each do |topic| 23 | yield topic 24 | end 25 | end 26 | 27 | def close! 28 | return unless @cons 29 | @cons.values.each do |conn| 30 | conn.close! 31 | end 32 | end 33 | 34 | private 35 | 36 | def classify(handlers) 37 | return [] unless handlers 38 | handlers.map do |handler| 39 | handler.split('::').inject(Object) do |const, name| 40 | const.const_get(name) 41 | end 42 | end 43 | end 44 | 45 | def get_conn(name) 46 | @cons ||= {}.tap do |cons| 47 | each_topic do |topic| 48 | debug "Connecting to RabbiMQ: topic: #{topic}, #{config.connection_properties}" 49 | cons[topic] = Lapine::Consumer::Connection.new(config, topic) 50 | end 51 | end 52 | @cons[name] 53 | end 54 | 55 | def debug(msg) 56 | return unless config.debug? 57 | return unless logger 58 | logger.info msg 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/lapine/consumer/dispatcher.rb: -------------------------------------------------------------------------------- 1 | require 'oj' 2 | require 'lapine/dtrace' 3 | 4 | module Lapine 5 | module Consumer 6 | class Dispatcher 7 | class DefaultErrorHandler 8 | def call(e, data, _metadata) 9 | $stderr.puts "Lapine::Dispatcher unable to dispatch, #{e.message}, data: #{data}" 10 | end 11 | end 12 | 13 | attr_reader :delegate_class, :message, :payload 14 | 15 | def self.error_handler=(handler) 16 | @error_handler = handler 17 | end 18 | 19 | def self.error_handler 20 | @error_handler || DefaultErrorHandler.new 21 | end 22 | 23 | def initialize(delegate_class, message) 24 | @delegate_class = delegate_class 25 | @message = message 26 | @payload = message.decoded_payload 27 | end 28 | 29 | def dispatch 30 | Lapine::DTrace.fire!(:dispatch_enter, delegate_class.name, message.payload) 31 | with_timed_logging(payload) { do_dispatch(payload) } 32 | Lapine::DTrace.fire!(:dispatch_return, delegate_class.name, message.payload) 33 | end 34 | 35 | private 36 | 37 | def with_timed_logging(json) 38 | time = Time.now 39 | ret = yield 40 | time_end = Time.now 41 | duration = (time_end - time) * 1000 42 | message.logger.info "Processing rabbit message handler:#{delegate_class.name} duration(ms):#{duration} routing_key:#{message.routing_key} payload:#{json.inspect}" 43 | ret 44 | end 45 | 46 | def delegate_method_names 47 | [:handle_lapine_payload, :perform_async] 48 | end 49 | 50 | def do_dispatch(payload) 51 | delegate_method_names.each do |meth| 52 | return delegate_class.send(meth, payload, message.metadata) if delegate_class.respond_to?(meth) 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/lapine/test/exchange.rb: -------------------------------------------------------------------------------- 1 | module Lapine 2 | module Test 3 | class FakeChannel 4 | attr_reader :queues 5 | 6 | def initialize 7 | @queues = {} 8 | end 9 | 10 | def queue(name = nil, opts = {}) 11 | @queues[name] ||= FakeQueue.new 12 | end 13 | end 14 | 15 | class FakeExchange 16 | attr_reader :histories 17 | 18 | def initialize 19 | @histories = [] 20 | end 21 | 22 | def channel 23 | @channel ||= FakeChannel.new 24 | end 25 | 26 | def bind(history) 27 | histories << history 28 | end 29 | 30 | def publish(body, routing_key = nil) 31 | histories.each do |h| 32 | h.publish(body, routing_key) 33 | end 34 | end 35 | end 36 | 37 | class FakeQueue 38 | attr_reader :exchange, :message_history 39 | 40 | def bind(exchange) 41 | @exchange = exchange 42 | @message_history = MessageHistory.new 43 | exchange.bind message_history 44 | self 45 | end 46 | 47 | def message_count 48 | message_history.message_count 49 | end 50 | 51 | def messages 52 | message_history.messages 53 | end 54 | end 55 | 56 | class MessageHistory 57 | attr_reader :messages 58 | 59 | def initialize 60 | @messages = [] 61 | end 62 | 63 | def publish(body, routing_key) 64 | messages << [body, routing_key] 65 | end 66 | 67 | def message_count 68 | messages.size 69 | end 70 | end 71 | 72 | class Exchange 73 | attr_reader :name 74 | 75 | def initialize(name, properties) 76 | @name = name 77 | end 78 | 79 | def exchange 80 | @exchange ||= FakeExchange.new 81 | end 82 | 83 | def close! 84 | @exchange = nil 85 | true 86 | end 87 | 88 | def connected? 89 | true 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/lib/lapine/consumer/dispatcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'lapine/consumer/dispatcher' 3 | 4 | RSpec.describe Lapine::Consumer::Dispatcher do 5 | 6 | subject(:dispatcher) { Lapine::Consumer::Dispatcher.new(delegate, message) } 7 | let(:message) do 8 | Lapine::Consumer::Message.new(json, metadata, logger).tap do |message| 9 | message['decoded_payload'] = hash 10 | end 11 | end 12 | 13 | let(:logger) { double('logger') } 14 | let(:hash) { {'foo' => 'bar'} } 15 | let(:json) { Oj.dump(hash) } 16 | let(:metadata) { double('metadata', routing_key: 'routing_key') } 17 | let(:delegate) { double('delegate', name: 'ClassName') } 18 | 19 | let(:caught_errors) { [] } 20 | 21 | after do 22 | Lapine::Consumer::Dispatcher.error_handler = nil 23 | end 24 | 25 | describe '#delegation' do 26 | context 'success cases' do 27 | before do 28 | expect(logger).to receive(:info).once.with(/Processing(.*)ClassName/) 29 | end 30 | 31 | context '.handle_lapine_payload method' do 32 | it 'receives handle_lapine_payload' do 33 | expect(delegate).to receive(:respond_to?).with(:handle_lapine_payload).and_return(true) 34 | expect(delegate).to receive(:handle_lapine_payload).once 35 | dispatcher.dispatch 36 | end 37 | end 38 | 39 | context '.perform_async method' do 40 | it 'receives perform_async' do 41 | expect(delegate).to receive(:respond_to?).with(:handle_lapine_payload).and_return(false) 42 | expect(delegate).to receive(:respond_to?).with(:perform_async).and_return(true) 43 | expect(delegate).to receive(:perform_async).once 44 | dispatcher.dispatch 45 | end 46 | end 47 | end 48 | end 49 | end 50 | 51 | RSpec.describe Lapine::Consumer::Dispatcher::DefaultErrorHandler do 52 | let(:payload) { double('payload') } 53 | let(:metadata) { double('metadata') } 54 | 55 | it 'puts to stderr' do 56 | expect($stderr).to receive(:puts) 57 | Lapine::Consumer::Dispatcher::DefaultErrorHandler.new.call(StandardError.new, payload, metadata) 58 | end 59 | end 60 | 61 | -------------------------------------------------------------------------------- /lib/lapine/configuration.rb: -------------------------------------------------------------------------------- 1 | module Lapine 2 | class Configuration 3 | def initialize 4 | @active_connections = {} 5 | end 6 | 7 | def connections 8 | @connections ||= {} 9 | end 10 | 11 | def connection_properties 12 | @connection_properties ||= {} 13 | end 14 | 15 | def channels_by_exchange_id 16 | @channels_by_exchange_id ||= {} 17 | end 18 | 19 | def register_channel(object_id, channel) 20 | channels_by_exchange_id[object_id] = channel 21 | end 22 | 23 | def cleanup_exchange(id) 24 | return unless channels_by_exchange_id[id] 25 | channel = channels_by_exchange_id[id] 26 | channel.connection.logger.info "Closing channel for exchange #{id}, thread: #{Thread.current.object_id}" 27 | channel.close 28 | channels_by_exchange_id[id] = nil 29 | end 30 | 31 | # Exchanges need to be saved in a thread-local variable, rather than a fiber-local variable, 32 | # because in the context of some applications (such as Sidekiq, which uses Celluloid) individual 33 | # bits of work are done in fibers that are immediately reaped. 34 | def exchanges 35 | Thread.current.thread_variable_get(:lapine_exchanges) || 36 | Thread.current.thread_variable_set(:lapine_exchanges, {}) 37 | end 38 | 39 | def exchange_properties 40 | @exchange_properties ||= {} 41 | end 42 | 43 | def active_connection(name) 44 | conn = @active_connections[name] 45 | return conn if (conn && conn.connected?) 46 | 47 | @active_connections[name] = begin 48 | @conn = Bunny.new(connection_props_for(name)).tap do |conn| 49 | conn.start 50 | end 51 | end 52 | end 53 | 54 | def close_connections! 55 | @active_connections.values.map(&:close) 56 | @active_connections = {} 57 | Thread.current.thread_variable_set(:lapine_exchanges, nil) 58 | end 59 | 60 | def connection_props_for(name) 61 | return unless connection_properties[name] 62 | connection_properties[name].dup.tap do |props| 63 | if defined?(Rails) 64 | props.merge!(logger: Rails.logger) 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/lapine/annotated_logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | module Lapine 3 | class AnnotatedLogger < Logger 4 | attr_accessor :log_method_caller # if set will log ruby method name substring from where logging is called 5 | attr_accessor :log_timestamps # if set will log timestamps up to millisecond 6 | attr_accessor :colorize_logging # if set turns on colors (hint: turn off in production) 7 | 8 | NUMBER_TO_COLOR_MAP = {"debug" => '0;37', "info" => '32', "warn" => '33', "error" => '31', "fatal" => '31', "unknown" => '37'} 9 | 10 | def initialize *args 11 | super *args 12 | [:info, :debug, :warn, :error, :fatal].each { |m| 13 | AnnotatedLogger.class_eval %Q! 14 | def #{m} arg=nil, &block 15 | level = "#{m}" 16 | pid = "%.5d:" % $$ 17 | if block_given? 18 | arg = yield 19 | end 20 | out = arg 21 | out = out.gsub(/\n/, ' ') unless (level == "fatal" || out =~ /\\w+\\.rb:\\d+:in/m) 22 | t = Time.now 23 | l = log_message(t, pid, level, out) 24 | super(l) if l 25 | end 26 | ! 27 | } 28 | end 29 | 30 | def log_message(t, pid, level, out) 31 | color_on = color_off = sql_color_on = "" 32 | if self.colorize_logging 33 | color = NUMBER_TO_COLOR_MAP[level.to_s] 34 | color_on = "\033[#{color}m" 35 | sql_color_on = "\033[34m" 36 | color_off = "\033[0m" 37 | end 38 | format_string = "" 39 | format_values = [] 40 | if self.log_timestamps 41 | format_string << "%s.%03d " 42 | format_values << [t.strftime("%Y-%m-%d %H:%M:%S"), t.usec / 1000] 43 | end 44 | format_string << "%s #{color_on}%6.6s#{color_off} " 45 | format_values << [pid, level] 46 | 47 | if self.log_method_caller 48 | file, line, method = caller_method 49 | format_string << "|%-40.40s " 50 | format_values << "#{File.basename(file)}:#{line}:#{method}" 51 | end 52 | 53 | format_string << "%s" 54 | format_values << [out] 55 | format_values.flatten! 56 | 57 | format_string % format_values 58 | end 59 | 60 | def caller_method 61 | parse_caller(caller(3).first) 62 | end 63 | 64 | def parse_caller(at) 65 | if /^(.+?):(\d+)(?::in `(.*)')?/ =~ at 66 | file = Regexp.last_match[1] 67 | line = Regexp.last_match[2].to_i 68 | method = Regexp.last_match[3] 69 | [file, line, method] 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/lib/lapine/consumer/runner_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'lapine/consumer/runner' 3 | require 'amqp' 4 | require 'em-spec/rspec' 5 | 6 | RSpec.describe Lapine::Consumer::Runner do 7 | include EM::SpecHelper 8 | 9 | class FakerHandler 10 | def self.handle_lapine_payload(payload, metadata) 11 | end 12 | end 13 | 14 | subject(:runner) { Lapine::Consumer::Runner.new(argv) } 15 | let(:argv) { [] } 16 | let(:queues) do 17 | [ 18 | { 19 | 'q' => '', 20 | 'topic' => 'testing.topic', 21 | 'routing_key' => 'testing.update', 22 | 'handlers' => 23 | [ 24 | 'FakerHandler' 25 | ] 26 | } 27 | ] 28 | end 29 | 30 | after :each do 31 | # Comment this out to see the log in the top level folder. 32 | `rm -f #{logfile}` 33 | end 34 | 35 | let(:logfile) { File.expand_path('../../../../../lapine.log', __FILE__) } 36 | let(:config) { double('config', 37 | logfile: logfile, 38 | yaml_config: 'fakefil', 39 | connection_properties: connection_properties, 40 | require: [], 41 | queues: queues, 42 | delete_queues: delete_queues, 43 | topics: ['testing.topic'], 44 | debug?: true, 45 | transient?: true) } 46 | let(:connection_properties) { {host: '127.0.0.1', port: 5672, ssl: false, vhost: '/', username: 'guest', password: 'guest'} } 47 | let(:message) { Oj.dump({'pay' => 'load'}) } 48 | let(:delete_queues) { [] } 49 | 50 | describe '#run' do 51 | before do 52 | allow(runner).to receive(:config).and_return(config) 53 | allow(runner).to receive(:topology).and_return(::Lapine::Consumer::Topology.new(config, runner.logger)) 54 | allow(runner).to receive(:handle_signals!) 55 | end 56 | 57 | it 'sends a message to handler' do 58 | expect(FakerHandler).to receive(:handle_lapine_payload).twice 59 | em do 60 | subject.run 61 | EventMachine.add_timer(0.5) { 62 | conn = Lapine::Consumer::Connection.new(config, 'testing.topic') 63 | conn.exchange.publish(message, routing_key: 'testing.update') 64 | conn.exchange.publish(message, routing_key: 'testing.update') 65 | } 66 | EventMachine.add_timer(1.0) { done } 67 | end 68 | end 69 | end 70 | 71 | describe '#config' do 72 | it 'passes argv to a new config object' do 73 | allow(Lapine::Consumer::Config).to receive(:new).and_return(config) 74 | expect(config).to receive(:load).with(argv).and_return(config) 75 | expect(runner.config).to eq(config) 76 | end 77 | end 78 | 79 | describe '#handle_signals!' do 80 | it 'traps INT and TERM signals' do 81 | expect(Signal).to receive(:trap).with('INT') 82 | expect(Signal).to receive(:trap).with('TERM') 83 | subject.handle_signals! 84 | end 85 | end 86 | end 87 | 88 | -------------------------------------------------------------------------------- /spec/lib/lapine/consumer/topology_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'lapine/consumer/topology' 3 | 4 | RSpec.describe Lapine::Consumer::Topology do 5 | module MessageBusTest 6 | class Clazz 7 | end 8 | end 9 | 10 | let(:topics) { 11 | [ 12 | 'a.topic', 13 | 'b.topic' 14 | ] 15 | } 16 | let(:queues) { 17 | [{ 18 | 'q' => 'store.buyable', 19 | 'topic' => 'a.topic', 20 | 'routing_key' => 'store.buyable.update', 21 | 'handlers' => ['MessageBusTest::Clazz'] 22 | }] 23 | } 24 | let(:connection_properties) { 25 | {} 26 | } 27 | let(:queues_to_delete) { [] } 28 | let(:config) do 29 | double('config', 30 | topics: topics, 31 | queues: queues, 32 | delete_queues: queues_to_delete, 33 | connection_properties: connection_properties, 34 | debug?: debug) 35 | end 36 | 37 | subject(:topology) { Lapine::Consumer::Topology.new(config, logger) } 38 | let(:debug) { false } 39 | let(:logger) { nil } 40 | 41 | describe '#each_topic' do 42 | it 'yields correct dount' do 43 | expect { |b| topology.each_topic(&b) }.to yield_control.twice 44 | end 45 | 46 | it 'yields all topics in order' do 47 | expect { |b| topology.each_topic(&b) }.to yield_successive_args('a.topic', 'b.topic') 48 | end 49 | end 50 | 51 | describe '#each_queue_to_delete' do 52 | let(:conn) { double('connection') } 53 | let(:queues_to_delete) { [ 54 | {'q' => 'queue.name', 'topic' => 'a.topic', 'handlers' => ['MessageBusTest::Clazz']}, 55 | {'q' => 'other.queue.name', 'topic' => 'a.topic', 'handlers' => ['MessageBusTest::Clazz']} 56 | ] } 57 | before do 58 | allow(Lapine::Consumer::Connection).to receive(:new) { conn } 59 | end 60 | 61 | it 'yields queue name with connection' do 62 | expect { |b| 63 | topology.each_queue_to_delete(&b) 64 | }.to yield_successive_args( 65 | ['queue.name', conn, nil, [MessageBusTest::Clazz]], 66 | ['other.queue.name', conn, nil, [MessageBusTest::Clazz]] 67 | ) 68 | end 69 | end 70 | 71 | describe '#each_binding' do 72 | let(:conn) { double('connection') } 73 | 74 | before do 75 | allow(Lapine::Consumer::Connection).to receive(:new) { conn } 76 | end 77 | 78 | it 'yields correct count' do 79 | expect { |b| topology.each_binding(&b) }.to yield_control.once 80 | end 81 | 82 | it 'yields expected arguments' do 83 | expect { |b| 84 | topology.each_binding(&b) 85 | }.to yield_with_args('store.buyable', 86 | conn, 87 | 'store.buyable.update', 88 | [MessageBusTest::Clazz]) 89 | end 90 | 91 | context 'with a logger and debug mode' do 92 | let(:debug) { true } 93 | let(:logger) { double('logger', info: true) } 94 | 95 | it 'logs each connection' do 96 | topology.each_binding {} 97 | expect(logger).to have_received(:info).with("Connecting to RabbiMQ: topic: a.topic, #{config.connection_properties}") 98 | expect(logger).to have_received(:info).with("Connecting to RabbiMQ: topic: b.topic, #{config.connection_properties}") 99 | end 100 | end 101 | end 102 | end 103 | 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Lapine 2 | ====== 3 | 4 | [![Gem Version](https://badge.fury.io/rb/lapine.svg)](http://badge.fury.io/rb/lapine) 5 | 6 | Speak to RabbitMQ. This gem serves as a wrapper for publishing messages 7 | to RabbitMQ via the Bunny gem, and for consuming messages using the AMQP 8 | gem. 9 | 10 | 11 | ## Configuration 12 | 13 | Initialization can be done inline in a daemon, or if used in Rails 14 | an initializer should be made at `config/initializers/lapine.rb` 15 | 16 | Register a connection. This connection should be given a name, and 17 | a hash of connection options that will be passed through to Bunny. 18 | 19 | ```ruby 20 | Lapine.add_connection 'my-connection', { 21 | host: 'my-rabbitmq.mine.com', 22 | port: 5672, 23 | user: 'rabbit', 24 | password: 'meow' 25 | } 26 | ``` 27 | 28 | Then register an exchange. 29 | 30 | ```ruby 31 | Lapine.add_exchange 'efrafa', 32 | durable: true, 33 | connection: 'my-connection', # required 34 | type: 'topic' # required 35 | ``` 36 | 37 | ## Publisher Usage 38 | 39 | Define a class that configures which `exchange` is used. This class 40 | must define `#to_hash` 41 | 42 | ```ruby 43 | require 'lapine' 44 | 45 | class Worker 46 | include Lapine::Publisher 47 | 48 | exchange 'efrafa' 49 | 50 | def initialize(action) 51 | @action = action 52 | end 53 | 54 | def to_hash 55 | { 56 | 'action' => @action 57 | } 58 | end 59 | end 60 | ``` 61 | 62 | This class can be used to publish messages onto its exchange: 63 | 64 | ```ruby 65 | Worker.new('dig').publish 66 | ``` 67 | 68 | Publishing can take a routing key for topic exchanges: 69 | 70 | ```ruby 71 | Worker.new('dig').publish('rabbits.drones') 72 | ``` 73 | 74 | Note that the `#initialize` method and the contents of `#to_hash` 75 | are arbitrary. 76 | 77 | 78 | ## Consumer Usage 79 | 80 | Please see the [Lapine wiki](https://github.com/wanelo/lapine/wiki) for documentation 81 | on defining and configuring consumers. 82 | 83 | 84 | ## But... WHY 85 | 86 | * This should be dead simple, but everything else was either too 87 | complex or assumed very specific configurations different from what 88 | we want. 89 | 90 | 91 | ## Testing 92 | 93 | Lapine comes with helpers to stub out calls to RabbitMQ. This allows you 94 | to write tests using Lapine, without having to actually run RabbitMQ in 95 | your test suite. 96 | 97 | ```ruby 98 | require 'lapine/test/rspec_helper' 99 | 100 | RSpec.configure do |config| 101 | config.include Lapine::Test::RSpecHelper, fake_rabbit: true 102 | 103 | config.before :each, :fake_rabbit do |example| 104 | Lapine::Test::RSpecHelper.setup(example) 105 | end 106 | 107 | config.after :each, :fake_rabbit do 108 | Lapine::Test::RSpecHelper.teardown 109 | end 110 | end 111 | ``` 112 | 113 | An example test would look something like this: 114 | 115 | ```ruby 116 | RSpec.describe MyPublisher, fake_rabbit: true do 117 | let(:exchange) { Lapine.find_exchange('my.topic') } 118 | let(:queue) { exchange.channel.queue.bind(exchange) } 119 | 120 | describe 'publishing' do 121 | it 'adds a message to a queue' do 122 | MyPublisher.new.publish('my.things') 123 | expect(queue.message_count).to eq(1) 124 | end 125 | end 126 | end 127 | ``` 128 | 129 | ## Contributing 130 | 131 | 1. Fork it ( https://github.com/[my-github-username]/lapine/fork ) 132 | 2. Create your feature branch (`git checkout -b my-new-feature`) 133 | 3. Commit your changes (`git commit -am 'Add some feature'`) 134 | 4. Push to the branch (`git push origin my-new-feature`) 135 | 5. Create a new Pull Request 136 | -------------------------------------------------------------------------------- /lib/lapine/consumer/config.rb: -------------------------------------------------------------------------------- 1 | require 'mixlib/cli' 2 | require 'yaml' 3 | 4 | module Lapine 5 | module Consumer 6 | class Config 7 | include Mixlib::CLI 8 | 9 | banner 'Usage: lapine consume (options)' 10 | 11 | option :config_file, 12 | short: '-c CONFIG_FILE', 13 | long: '--config CONFIG_FILE', 14 | description: 'YML file with configuration of subscribers', 15 | required: true 16 | 17 | option :logfile, 18 | short: '-l LOGFILE', 19 | long: '--logfile LOGFILE', 20 | description: 'where to log consumer info (default to STDOUT)', 21 | required: false 22 | 23 | option :host, 24 | short: '-H RABBIT_HOST', 25 | long: '--host RABBIT_HOST', 26 | description: 'IP or FQDN of RabbitMQ host (default 127.0.0.1)' 27 | 28 | option :port, 29 | short: '-p RABBIT_PORT', 30 | long: '--port RABBIT_PORT', 31 | description: 'port to use with RabbitMQ (default 5672)' 32 | 33 | option :ssl, 34 | short: '-S', 35 | long: '--ssl', 36 | description: 'use ssl to connect (default false)' 37 | 38 | option :vhost, 39 | short: '-V VHOST', 40 | long: '--vhost VHOST', 41 | description: 'RabbitMQ vhost to use (default "/")' 42 | 43 | option :username, 44 | short: '-U USERNAME', 45 | long: '--username USERNAME', 46 | description: 'RabbitMQ user (default guest)' 47 | 48 | option :password, 49 | short: '-P PASSWORD', 50 | long: '--password PASSWORD', 51 | description: 'RabbitMQ password (default guest)' 52 | 53 | option :transient, 54 | long: '--transient', 55 | description: 'Auto-delete queues when workers stop', 56 | default: false 57 | 58 | option :debug, 59 | long: '--debug', 60 | description: 'More verbose (and possibly non-threadsafe) log statements', 61 | default: false 62 | 63 | option :help, 64 | short: '-?', 65 | long: '--help', 66 | description: 'Show this message', 67 | on: :tail, 68 | boolean: true, 69 | show_options: true, 70 | exit: 0 71 | 72 | def load(argv) 73 | parse_options argv 74 | self 75 | end 76 | 77 | def debug? 78 | config[:debug] 79 | end 80 | 81 | def logfile 82 | config[:logfile] 83 | end 84 | 85 | def delete_queues 86 | yaml_config['delete_queues'] || [] 87 | end 88 | 89 | def queues 90 | yaml_config['queues'] || [] 91 | end 92 | 93 | def require 94 | yaml_config['require'] || [] 95 | end 96 | 97 | def topics 98 | yaml_config['topics'] 99 | end 100 | 101 | def transient? 102 | config[:transient] 103 | end 104 | 105 | def connection_properties 106 | { 107 | host: '127.0.0.1', 108 | port: 5672, 109 | ssl: false, 110 | vhost: '/', 111 | username: 'guest', 112 | password: 'guest' 113 | }.merge(file_connection_props) 114 | .merge(cli_connection_props) 115 | end 116 | 117 | private 118 | 119 | def file_connection_props 120 | return {} unless yaml_config['connection'] 121 | yaml_config['connection'].inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo} 122 | end 123 | 124 | def cli_connection_props 125 | { 126 | host: config[:host], 127 | port: config[:port] ? config[:port].to_i : nil, 128 | ssl: config[:ssl], 129 | vhost: config[:vhost], 130 | username: config[:username], 131 | password: config[:password] 132 | }.delete_if { |k, v| v.nil? } 133 | end 134 | 135 | def yaml_config 136 | @yaml ||= YAML.load_file(config[:config_file]) 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /spec/lib/lapine_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'lapine' 3 | require 'lapine/consumer' 4 | 5 | RSpec.describe Lapine do 6 | let(:connection) { double('connection', close: true, logger: logger) } 7 | let(:logger) { double('bunny logger', info: true) } 8 | let(:connection_properties) do 9 | { 10 | host: 'someplace.com' 11 | } 12 | end 13 | 14 | let(:config) { Lapine::Configuration.new } 15 | 16 | before do 17 | Lapine.instance_variable_set(:@config, config) 18 | end 19 | 20 | describe '.add_connection' do 21 | it 'saves the connection information' do 22 | Lapine.add_connection 'my-connection', connection_properties 23 | expect(config.connection_properties['my-connection']).to eq(connection_properties) 24 | end 25 | end 26 | 27 | describe '.add_exchange' do 28 | context 'when connection has been defined' do 29 | before do 30 | config.connection_properties['my-connection'] = {} 31 | end 32 | 33 | it 'saves the exchange information' do 34 | Lapine.add_exchange 'my-exchange', durable: false, connection: 'my-connection' 35 | expect(config.exchange_properties['my-exchange']).to eq({ 36 | durable: false, 37 | connection: 'my-connection' 38 | }) 39 | end 40 | end 41 | 42 | context 'when connection has not been defined' do 43 | it 'raises' do 44 | expect { 45 | Lapine.add_exchange 'my-exchange', durable: false, connection: 'my-connection' 46 | }.to raise_error(Lapine::UndefinedConnection) 47 | end 48 | end 49 | end 50 | 51 | describe '.close_connections!' do 52 | let(:connection1) { double('blah connection', close: true, name: 'blah') } 53 | let(:connection2) { double('blargh connection', close: true, name: 'blargh') } 54 | 55 | before do 56 | config.instance_variable_set(:@active_connections, { 57 | 'blah' => connection1, 58 | 'blargh' => connection2 59 | }) 60 | end 61 | 62 | it 'calls close on each connection' do 63 | Lapine.close_connections! 64 | expect(connection1).to have_received(:close) 65 | expect(connection2).to have_received(:close) 66 | end 67 | 68 | it 'clears the exchanges' do 69 | Lapine.close_connections! 70 | expect(Lapine.config.exchanges).to be_empty 71 | end 72 | end 73 | 74 | describe '.find_exchange' do 75 | before do 76 | allow(Bunny).to receive(:new).and_return(connection) 77 | end 78 | 79 | context 'when exchange has not been registered' do 80 | it 'raises' do 81 | expect { 82 | Lapine.find_exchange 'non-existent-exchange' 83 | }.to raise_error(Lapine::UndefinedExchange) 84 | end 85 | end 86 | 87 | context 'when exchange has been registered' do 88 | let(:channel) { double('channel', connection: connection, open?: true) } 89 | let(:exchange) { double('exchange', channel: channel) } 90 | 91 | before do 92 | allow(connection).to receive(:start) 93 | allow(connection).to receive(:create_channel).and_return(channel) 94 | allow(Bunny::Exchange).to receive(:new).and_return(exchange) 95 | config.exchange_properties['my-exchange'] = {connection: 'my-connection', type: :thing, some: 'exchange-property'} 96 | end 97 | 98 | context 'when exchanges has not been created' do 99 | before do 100 | allow(connection).to receive(:connected?).and_return(true) 101 | end 102 | 103 | it 'returns an exchange' do 104 | expect(Lapine.find_exchange('my-exchange')).to eq(exchange) 105 | end 106 | 107 | it 'creates the exchange with its configured properties' do 108 | Lapine.find_exchange('my-exchange') 109 | expect(Bunny::Exchange).to have_received(:new).with(channel, :thing, 'my-exchange', some: 'exchange-property') 110 | end 111 | 112 | it 'starts a connection and creates a channel' do 113 | Lapine.find_exchange('my-exchange') 114 | expect(connection).to have_received(:start) 115 | expect(connection).to have_received(:create_channel) 116 | end 117 | 118 | it 'only creates exchange once' do 119 | Lapine.find_exchange('my-exchange') 120 | Lapine.find_exchange('my-exchange') 121 | expect(connection).to have_received(:start).once 122 | expect(connection).to have_received(:create_channel).once 123 | end 124 | end 125 | 126 | context 'with a created exchange' do 127 | context 'that is closed' do 128 | it 'establishes a new connection and exchange' do 129 | Lapine.find_exchange('my-exchange') 130 | allow(connection).to receive(:connected?).and_return(false) 131 | Lapine.find_exchange('my-exchange') 132 | expect(connection).to have_received(:start).twice 133 | expect(connection).to have_received(:create_channel).twice 134 | end 135 | end 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/lapine/consumer/runner.rb: -------------------------------------------------------------------------------- 1 | require 'amqp' 2 | require 'digest' 3 | require 'environmenter' 4 | require 'eventmachine' 5 | require 'lapine/annotated_logger' 6 | require 'lapine/consumer/config' 7 | require 'lapine/consumer/connection' 8 | require 'lapine/consumer/message' 9 | require 'lapine/consumer/middleware' 10 | require 'lapine/consumer/topology' 11 | require 'lapine/consumer/dispatcher' 12 | 13 | module Lapine 14 | module Consumer 15 | class Runner 16 | attr_reader :argv 17 | 18 | def initialize(argv) 19 | @argv = argv 20 | @message_count = 0 21 | @running_message_count = 0 22 | end 23 | 24 | def run 25 | handle_signals! 26 | Environmenter::Loader.new(config).load! 27 | logger.info 'starting Lapine::Consumer' 28 | 29 | @queue_properties = queue_properties 30 | EventMachine.run do 31 | topology.each_binding do |q, conn, routing_key, handlers| 32 | queue = conn.channel.queue(q, @queue_properties).bind(conn.exchange, routing_key: routing_key) 33 | queue.subscribe(ack: true) do |metadata, payload| 34 | process(metadata, payload, handlers) 35 | EventMachine.stop_event_loop if should_exit? 36 | end 37 | queues << queue 38 | end 39 | 40 | topology.each_queue_to_delete do |q, conn, routing_key, handlers| 41 | # if queue does not exist in RabbitMQ, skip processing 42 | # else 43 | queue = conn.channel.queue(q, @queue_properties) 44 | queues_to_delete << queue 45 | 46 | queue.subscribe(ack: true) do |metadata, payload| 47 | process(metadata, payload, handlers) 48 | end 49 | 50 | EventMachine.add_timer(0.5) do 51 | logger.info "Lapine::Consumer unbinding #{queue.name} from exchange: #{conn.exchange.name}, routing_key: #{routing_key}" 52 | queue.unbind(conn.exchange, routing_key: routing_key) 53 | end 54 | end 55 | 56 | if config.debug? 57 | EventMachine.add_periodic_timer(10) do 58 | logger.info "Lapine::Consumer messages processed=#{@message_count} running_count=#{@running_message_count}" 59 | @message_count = 0 60 | end 61 | end 62 | 63 | EventMachine.add_periodic_timer(5) do 64 | EventMachine.stop_event_loop if should_exit? 65 | end 66 | 67 | schedule_queue_deletion 68 | end 69 | 70 | logger.warn 'exiting Lapine::Consumer' 71 | end 72 | 73 | def config 74 | @config ||= Lapine::Consumer::Config.new.load(argv) 75 | end 76 | 77 | def topology 78 | @topology ||= ::Lapine::Consumer::Topology.new(config, logger) 79 | end 80 | 81 | def logger 82 | @logger ||= config.logfile ? ::Lapine::AnnotatedLogger.new(config.logfile) : ::Lapine::AnnotatedLogger.new(STDOUT) 83 | end 84 | 85 | def queue_properties 86 | {}.tap do |props| 87 | props.merge!(auto_delete: true) if config.transient? 88 | props.merge!(durable: true) unless config.transient? 89 | end 90 | end 91 | 92 | def should_exit? 93 | $STOP_LAPINE_CONSUMER 94 | end 95 | 96 | def handle_signals! 97 | $STOP_LAPINE_CONSUMER = false 98 | Signal.trap('INT') { EventMachine.stop } 99 | Signal.trap('TERM') { $STOP_LAPINE_CONSUMER = true } 100 | end 101 | 102 | private 103 | 104 | def queues 105 | @queues ||= [] 106 | end 107 | 108 | def queues_to_delete 109 | @queues_to_delete ||= [] 110 | end 111 | 112 | def schedule_queue_deletion 113 | EventMachine.add_timer(30) do 114 | queues_to_delete.each do |queue| 115 | logger.info "Lapine::Consumer checking #{queue.name} for deletion" 116 | 117 | begin 118 | queue.status do |message_count, consumer_count| 119 | if message_count == 0 120 | logger.info "Lapine::Consumer deleting #{queue.name}" 121 | queue.unsubscribe 122 | queue.delete unless config.transient? 123 | queues_to_delete.delete(queue) 124 | else 125 | logger.info "Lapine::Consumer skipping #{queue.name} deletion, message count: #{message_count}" 126 | schedule_queue_deletion 127 | end 128 | end 129 | rescue => e 130 | logger.error "Unable to delete queue #{queue.name}, error: #{e.message}" 131 | end 132 | end 133 | end 134 | end 135 | 136 | def process(metadata, payload, handlers) 137 | message = Consumer::Message.new(payload, metadata, logger) 138 | Middleware.app.call(message) do |message| 139 | handlers.each do |handler| 140 | Lapine::Consumer::Dispatcher.new(handler, message).dispatch 141 | end 142 | 143 | if config.debug? 144 | @message_count += 1 145 | @running_message_count += 1 146 | end 147 | end 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/lib/lapine/consumer/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'lapine/consumer/dispatcher' 3 | require 'lapine/consumer/middleware' 4 | 5 | RSpec.describe Lapine::Consumer::Middleware do 6 | class MiddlewareAddLetter 7 | def initialize(app, letter) 8 | @app = app 9 | @letter = letter 10 | end 11 | 12 | def call(message) 13 | message['letter'] = @letter 14 | @app.call(message) 15 | end 16 | end 17 | 18 | class MiddlewareCopyLetter 19 | def initialize(app) 20 | @app = app 21 | end 22 | 23 | def call(message) 24 | message['middleware.copy_letter.ran'] = true 25 | message['duplicate_letter'] = message['letter'] 26 | @app.call(message) 27 | end 28 | end 29 | 30 | class RaisingMiddleware 31 | def initialize(app) 32 | @app = app 33 | end 34 | 35 | def call(_message) 36 | raise StandardError.new('Raise') 37 | end 38 | end 39 | 40 | class CatchingMiddleWare 41 | def initialize(app) 42 | @app = app 43 | end 44 | 45 | def call(message) 46 | @app.call(message) 47 | rescue StandardError => e 48 | message['error_message'] = e.message 49 | end 50 | end 51 | 52 | let(:metadata) { double('metadata', ack: true) } 53 | let(:payload) { '{}' } 54 | let(:message) { Lapine::Consumer::Message.new(payload, metadata, nil) } 55 | 56 | describe '.add' do 57 | before do 58 | Lapine::Consumer::Middleware.tap do |middleware| 59 | middleware.add MiddlewareAddLetter, 'f' 60 | end 61 | end 62 | 63 | it 'is adds letter to hash' do 64 | Lapine::Consumer::Middleware.app.call(message) do |message| 65 | expect(message['letter']).to eq('f') 66 | end 67 | end 68 | 69 | context 'when duplicate middleware is added' do 70 | it 'raises' do 71 | expect { 72 | Lapine::Consumer::Middleware.tap do |middleware| 73 | middleware.add MiddlewareAddLetter, 'f' 74 | end 75 | }.to raise_error(Middlewear::DuplicateMiddleware) 76 | end 77 | end 78 | end 79 | 80 | describe '.delete' do 81 | let(:registry) { Lapine::Consumer::Middleware.registry } 82 | 83 | before do 84 | Lapine::Consumer::Middleware.tap do |middleware| 85 | middleware.add MiddlewareAddLetter, 'f' 86 | end 87 | end 88 | 89 | it 'removes register that matches class name' do 90 | expect(registry.index_of(MiddlewareAddLetter)).to be 91 | Lapine::Consumer::Middleware.delete(MiddlewareAddLetter) 92 | expect(registry.index_of(MiddlewareAddLetter)).not_to be 93 | end 94 | end 95 | 96 | describe 'error handling' do 97 | describe 'with default middleware' do 98 | let(:error) { StandardError.new('doh') } 99 | 100 | before do 101 | Lapine::Consumer::Middleware::DEFAULT_MIDDLEWARE.each do |mw| 102 | Lapine::Consumer::Middleware.add(mw) 103 | end 104 | end 105 | 106 | it 'runs through the dispatcher error_handler' do 107 | errors = [] 108 | Lapine::Consumer::Dispatcher.error_handler = ->(e, data, md) { 109 | errors << [e, data, md] 110 | } 111 | Lapine::Consumer::Middleware.app.call(message) { raise error } 112 | expect(errors).to include([error, message.payload, message.metadata]) 113 | end 114 | end 115 | 116 | describe 'with custom middleware' do 117 | before do 118 | Lapine::Consumer::Middleware.tap do |middleware| 119 | middleware.add CatchingMiddleWare 120 | middleware.add RaisingMiddleware 121 | end 122 | end 123 | 124 | it 'catches error' do 125 | Lapine::Consumer::Middleware.app.call(message) 126 | expect(message['error_message']).to eq('Raise') 127 | end 128 | 129 | it 'halts execution' do 130 | expectation = double(called: true) 131 | Lapine::Consumer::Middleware.app.call(message) do 132 | expectation.called 133 | end 134 | expect(expectation).not_to have_received(:called) 135 | end 136 | end 137 | end 138 | 139 | describe '.add_before' do 140 | before do 141 | Lapine::Consumer::Middleware.tap do |middleware| 142 | middleware.add MiddlewareAddLetter, 'f' 143 | middleware.add_before MiddlewareAddLetter, MiddlewareCopyLetter 144 | end 145 | 146 | it 'prepends middleware' do 147 | Lapine::Consumer::Middleware.app.call(message) do |message| 148 | expect(message['letter']).to eq('f') 149 | expect(message['duplicate_letter']).to be nil 150 | expect(message['middleware.copy_letter.ran']).to be true 151 | end 152 | end 153 | end 154 | end 155 | 156 | context '.add_after' do 157 | before do 158 | Lapine::Consumer::Middleware.tap do |middleware| 159 | middleware.add MiddlewareAddLetter, 'f' 160 | middleware.add_after MiddlewareAddLetter, MiddlewareCopyLetter 161 | end 162 | 163 | it 'prepends middleware' do 164 | Lapine::Consumer::Middleware.app.call(message) do |message| 165 | expect(message['letter']).to eq('f') 166 | expect(message['duplicate_letter']).to be 'f' 167 | expect(message['middleware.copy_letter.ran']).to be true 168 | end 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /spec/lib/lapine/consumer/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'lapine/consumer/config' 3 | 4 | RSpec.describe Lapine::Consumer::Config do 5 | let(:argv) { %w(-c /path/to/config.yml) } 6 | 7 | subject(:config) { Lapine::Consumer::Config.new } 8 | let(:config_from_file) { {} } 9 | 10 | before do 11 | config.load argv 12 | allow(YAML).to receive(:load_file).with('/path/to/config.yml').and_return(config_from_file) 13 | end 14 | 15 | describe '#load' do 16 | it 'returns self' do 17 | expect(config.load(argv)).to eq(config) 18 | end 19 | end 20 | 21 | describe '#connection_properties' do 22 | before { config.load(argv) } 23 | 24 | let(:connection_properties) { config.connection_properties } 25 | 26 | describe 'host' do 27 | it 'defaults to 127.0.0.1' do 28 | expect(connection_properties[:host]).to eq('127.0.0.1') 29 | end 30 | 31 | context 'with connection info in file' do 32 | let(:config_from_file) { {'connection' => {'host' => '1.1.1.1'}} } 33 | 34 | it 'uses the config file info' do 35 | expect(connection_properties[:host]).to eq('1.1.1.1') 36 | end 37 | end 38 | 39 | context 'with command line arg' do 40 | let(:argv) { %w(--host 2.2.2.2 -c /path/to/config.yml) } 41 | let(:config_from_file) { {'connection' => {'host' => '1.1.1.1'}} } 42 | 43 | it 'prefers the cli' do 44 | expect(connection_properties[:host]).to eq('2.2.2.2') 45 | end 46 | end 47 | end 48 | 49 | describe 'port' do 50 | it 'defaults to 5672' do 51 | expect(connection_properties[:port]).to eq(5672) 52 | end 53 | 54 | context 'with connection info in file' do 55 | let(:config_from_file) { {'connection' => {'port' => 5673}} } 56 | 57 | it 'uses the config file info' do 58 | expect(connection_properties[:port]).to eq(5673) 59 | end 60 | end 61 | 62 | context 'with command line arg' do 63 | let(:argv) { %w(--port 5674 -c /path/to/config.yml) } 64 | let(:config_from_file) { {'connection' => {'port' => 5673}} } 65 | 66 | it 'prefers the cli' do 67 | expect(connection_properties[:port]).to eq(5674) 68 | end 69 | end 70 | end 71 | 72 | describe 'ssl' do 73 | it 'defaults to false' do 74 | expect(connection_properties[:ssl]).to be(false) 75 | end 76 | 77 | context 'with connection info in file' do 78 | let(:config_from_file) { {'connection' => {'ssl' => true}} } 79 | 80 | it 'uses the config file info' do 81 | expect(connection_properties[:ssl]).to be(true) 82 | end 83 | end 84 | 85 | context 'with command line arg' do 86 | let(:argv) { %w(--ssl -c /path/to/config.yml) } 87 | let(:config_from_file) { {'connection' => {'ssl' => false}} } 88 | 89 | it 'prefers the cli' do 90 | expect(connection_properties[:ssl]).to be(true) 91 | end 92 | end 93 | end 94 | 95 | describe 'vhost' do 96 | it 'defaults to /' do 97 | expect(connection_properties[:vhost]).to eq('/') 98 | end 99 | 100 | context 'with connection info in file' do 101 | let(:config_from_file) { {'connection' => {'vhost' => '/blah'}} } 102 | 103 | it 'uses the config file info' do 104 | expect(connection_properties[:vhost]).to eq('/blah') 105 | end 106 | end 107 | 108 | context 'with command line arg' do 109 | let(:argv) { %w(--vhost /argh -c /path/to/config.yml) } 110 | let(:config_from_file) { {'connection' => {'vhost' => '/blah'}} } 111 | 112 | it 'prefers the cli' do 113 | expect(connection_properties[:vhost]).to eq('/argh') 114 | end 115 | end 116 | end 117 | 118 | describe 'username' do 119 | it 'defaults to guest' do 120 | expect(connection_properties[:username]).to eq('guest') 121 | end 122 | 123 | context 'with connection info in file' do 124 | let(:config_from_file) { {'connection' => {'username' => 'Hrairoo'}} } 125 | 126 | it 'uses the config file info' do 127 | expect(connection_properties[:username]).to eq('Hrairoo') 128 | end 129 | end 130 | 131 | context 'with command line arg' do 132 | let(:argv) { %w(--username Thlayli -c /path/to/config.yml) } 133 | let(:config_from_file) { {'connection' => {'username' => 'Hrairoo'}} } 134 | 135 | it 'prefers the cli' do 136 | expect(connection_properties[:username]).to eq('Thlayli') 137 | end 138 | end 139 | end 140 | 141 | describe 'password' do 142 | it 'defaults to guest' do 143 | expect(connection_properties[:password]).to eq('guest') 144 | end 145 | 146 | context 'with connection info in file' do 147 | let(:config_from_file) { {'connection' => {'password' => 'flayrah'}} } 148 | 149 | it 'uses the config file info' do 150 | expect(connection_properties[:password]).to eq('flayrah') 151 | end 152 | end 153 | 154 | context 'with command line arg' do 155 | let(:argv) { %w(--password pfeffa -c /path/to/config.yml) } 156 | let(:config_from_file) { {'connection' => {'password' => 'flayrah'}} } 157 | 158 | it 'prefers the cli' do 159 | expect(connection_properties[:password]).to eq('pfeffa') 160 | end 161 | end 162 | end 163 | end 164 | 165 | describe '#queues' do 166 | let(:config_from_file) { {} } 167 | 168 | it 'defaults to empty array' do 169 | expect(config.queues).to eq([]) 170 | end 171 | end 172 | 173 | describe '#delete_queues' do 174 | let(:config_from_file) { {} } 175 | 176 | it 'defaults to empty array' do 177 | expect(config.delete_queues).to eq([]) 178 | end 179 | 180 | context 'with an array' do 181 | let(:config_from_file) { {'delete_queues' => ['a.b.c', 'e.f.g']} } 182 | 183 | it 'reads from config' do 184 | expect(config.delete_queues).to eq(['a.b.c', 'e.f.g']) 185 | end 186 | 187 | end 188 | end 189 | 190 | describe '#transient?' do 191 | it 'is false by default' do 192 | expect(config.transient?).to be false 193 | end 194 | 195 | context 'with --transient' do 196 | let(:argv) { %w(--transient -c /path/to/config.yml) } 197 | 198 | it 'is true' do 199 | expect(config.transient?).to be true 200 | end 201 | end 202 | end 203 | end 204 | --------------------------------------------------------------------------------