commit 86d952da9ace0044a5548522050cc9c2e4aa791a Author: Jim Myhrberg Date: Tue Feb 28 13:56:27 2017 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eb3b06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..8c18f1a --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--format documentation +--color diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..074bd4e --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,5 @@ +Documentation: + Enabled: false + +Style/HashSyntax: + EnforcedStyle: ruby19_no_mixed_keys diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6ddb514 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +sudo: false +language: ruby +rvm: + - 2.4.0 +before_install: gem install bundler -v 1.14.3 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..5034345 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in bunnyrun.gemspec +gemspec diff --git a/README.md b/README.md new file mode 100644 index 0000000..3164259 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# BunnyRun + +Easy to use runtime for [Bunny](http://rubybunny.info/)-based AMQP consumers. + +## Features + +- Simple and powerful DSL for writing consumers and publishers. +- CLI tool for running consumers (`bunnyrun my_consumer.rb`). +- Logging mechanisms. +- Error reporting mechanisms. + +## License + +The gem is available as open source under the terms of +the [MIT License](http://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..4c774a2 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..6e7f2a7 --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "bunnyrun" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/bunnyrun.gemspec b/bunnyrun.gemspec new file mode 100644 index 0000000..53a337b --- /dev/null +++ b/bunnyrun.gemspec @@ -0,0 +1,32 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'bunnyrun/version' + +Gem::Specification.new do |spec| + spec.name = 'bunnyrun' + spec.version = BunnyRun::VERSION + spec.authors = ['Jim Myhrberg'] + spec.email = ['contact@jimeh.me'] + + spec.summary = 'Easy to use runtime for bunny-based AMQP consumers.' + spec.description = 'Easy to use runtime for bunny-based AMQP consumers.' + spec.homepage = 'https://github.com/jimeh/bunnyrun' + + spec.files = `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^(bin|test|spec|features)/}) + end + + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + spec.add_development_dependency 'bundler', '~> 1.14' + spec.add_development_dependency 'rake', '~> 10.0' + spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'rubocop', '~> 0.47' + spec.add_development_dependency 'byebug' + + spec.add_runtime_dependency 'bunny', '~> 2.6' + spec.add_runtime_dependency 'trollop', '~> 2.1.2' +end diff --git a/exe/bunnyrun b/exe/bunnyrun new file mode 100755 index 0000000..bc2d040 --- /dev/null +++ b/exe/bunnyrun @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +$LOAD_PATH.unshift(File.expand_path('../../lib', File.realpath(__FILE__))) +require 'bunnyrun' + +BunnyRun::CLI.run(ARGV) diff --git a/lib/bunny_run.rb b/lib/bunny_run.rb new file mode 100644 index 0000000..1869f9b --- /dev/null +++ b/lib/bunny_run.rb @@ -0,0 +1 @@ +require 'bunnyrun' diff --git a/lib/bunnyrun.rb b/lib/bunnyrun.rb new file mode 100644 index 0000000..4a28756 --- /dev/null +++ b/lib/bunnyrun.rb @@ -0,0 +1,21 @@ +require 'bunnyrun/consumer' +require 'bunnyrun/cli' +require 'bunnyrun/runner' +require 'bunnyrun/version' + +module BunnyRun + class << self + def publish(exchange_name, payload, attrs = {}); end + + def after_start(&block) + callbacks[:after_start] ||= [] + callbacks[:after_start] << block + end + + private + + def callbacks + @callbacks ||= {} + end + end +end diff --git a/lib/bunnyrun/cli.rb b/lib/bunnyrun/cli.rb new file mode 100644 index 0000000..c3f24f3 --- /dev/null +++ b/lib/bunnyrun/cli.rb @@ -0,0 +1,30 @@ +require 'bunnyrun/consumer' +require 'bunnyrun/options' +require 'bunnyrun/runner' + +module BunnyRun + class CLI + attr_reader :options + + def self.run(argv = []) + new.run(argv) + end + + def run(argv = []) + options = Options.parse(argv) + require_files(options.paths) + consumers = Consumer.children + + runner = Runner.new(options, consumers) + runner.run + end + + private + + def require_files(paths) + paths.each do |path| + require File.join(Dir.pwd, path) + end + end + end +end diff --git a/lib/bunnyrun/consumer.rb b/lib/bunnyrun/consumer.rb new file mode 100644 index 0000000..4b56c57 --- /dev/null +++ b/lib/bunnyrun/consumer.rb @@ -0,0 +1,128 @@ +require 'bunnyrun/message' + +module BunnyRun + class Consumer + attr_reader :connection + attr_reader :publish_channel + attr_reader :default_prefetch + attr_reader :logger + + class << self + def inherited(klass) + children << klass + end + + def children + @children ||= [] + end + + def queue(name = nil, attrs = {}) + return @queue if name.nil? + @queue = { name: name, attrs: attrs } + end + + def exchange(name, attrs = {}) + exchanges[name] = attrs + end + + def bind(exchange_name, attrs = {}) + bindings << [exchange_name, attrs] + end + + def manual_ack(value = nil) + return @manual_ack || false if value.nil? + @manual_ack = value + end + + def prefetch(count = nil) + return @prefetch if count.nil? + @prefetch = count + end + + def exchanges + @exchanges ||= {} + end + + def bindings + @bindings ||= [] + end + end + + def initialize(opts = {}) + @connection = opts[:connection] + @publish_channel = opts[:publish_channel] + @default_prefetch = opts[:default_prefetch] + @logger = opts[:logger] + end + + def channel + @channel ||= connection.create_channel + end + + def start + perform_bindings + set_prefetch + subscribe + end + + def subscribe + opts = { manual_ack: self.class.manual_ack } + queue.subscribe(opts) do |delivery_info, properties, payload| + message = Message.new(delivery_info, properties, payload) + perform(message) + end + end + + def publish(exchange_name, payload, attrs = {}) + exch = publish_exchange(exchange_name) + exch.publish(payload, attrs) + end + + def queue + @queue ||= begin + opts = self.class.queue + channel.queue(opts[:name], opts[:attrs]) + end + end + + def exchange(name) + exchanges[name] ||= begin + return unless self.class.exchanges.key?(name) + + attrs = self.class.exchanges[name] + channel.exchange(name, attrs) + end + end + + def publish_exchange(name) + publish_exchanges[name] ||= begin + return unless self.class.exchanges.key?(name) + + attrs = self.class.exchanges[name] + publish_channel.exchange(name, attrs) + end + end + + private + + def perform_bindings + self.class.bindings.each do |(exchange_name, attrs)| + exch = exchange(exchange_name) + queue.bind(exch, attrs) + end + end + + def set_prefetch + count = self.class.prefetch || default_prefetch + channel.prefetch(count, true) + end + + def exchanges + @exchanges ||= {} + end + + def publish_exchanges + @publish_exchanges ||= {} + end + end +end diff --git a/lib/bunnyrun/core_ext/string.rb b/lib/bunnyrun/core_ext/string.rb new file mode 100644 index 0000000..23fea8b --- /dev/null +++ b/lib/bunnyrun/core_ext/string.rb @@ -0,0 +1,5 @@ +class String + def undent + gsub(/^.{#{slice(/^ +/).length}}/, '') + end +end diff --git a/lib/bunnyrun/message.rb b/lib/bunnyrun/message.rb new file mode 100644 index 0000000..567160b --- /dev/null +++ b/lib/bunnyrun/message.rb @@ -0,0 +1,49 @@ +module BunnyRun + class Message + attr_reader :delivery_info + attr_reader :properties + attr_reader :payload + + def initialize(delivery_info, properties, payload) + @delivery_info = delivery_info + @properties = properties + @payload = payload + @acked = false + end + + def channel + delivery_info.channel + end + + def ack + channel.ack(delivery_tag) + @acked = true + end + + def reject + channel.reject(delivery_tag, false) + @acked = true + end + + def requeue + channel.reject(delivery_tag, true) + @acked = true + end + + def manual_ack? + !delivery_info.consumer.no_ack + end + + def routing_key + delivery_info.routing_key + end + + def delivery_mode + properties.delivery_mode + end + + def delivery_tag + delivery_info.delivery_tag + end + end +end diff --git a/lib/bunnyrun/options.rb b/lib/bunnyrun/options.rb new file mode 100644 index 0000000..fba617c --- /dev/null +++ b/lib/bunnyrun/options.rb @@ -0,0 +1,152 @@ +require 'trollop' + +require 'bunnyrun/core_ext/string' + +module BunnyRun + class Options + class << self + def parse(argv = []) + args = argv.clone + + opts = parse_args(args) + opts[:paths] = args + + validate_paths(opts) + + opts.each_with_object(new) do |(key, value), memo| + memo.send("#{key}=", value) if memo.respond_to?("#{key}=") + end + end + + private + + def parse_args(args) + Trollop.with_standard_exception_handling(parser) do + parser.parse(args) + end + end + + def validate_paths(opts) + parser.die('One or more paths to consumers required', nil) \ + if opts[:paths].empty? + end + + def parser + return @parser if @parser + + defaults = new + @parser = Trollop::Parser.new do + banner <<-EOF.undent + Usage: bunnyrun [options] [path ...] + + Options: + EOF + + version "bunnyrun #{BunnyRun::VERSION}" + opt :url, 'Connection string ' \ + '(example: "amqp://guest:guest@127.0.0.1:5672/vhost")', + short: 'U', type: :string, default: defaults.url + opt :host, 'Host', + short: 'H', type: :string, default: defaults.host + opt :port, 'Port', + short: 'P', type: :int, default: defaults.port + opt :ssl, 'Connect using SSL', + short: 's', type: :bool, default: defaults.ssl + opt :vhost, 'Virtual host', + short: 'V', type: :string, default: defaults.vhost + opt :user, 'Username', + short: 'u', type: :string, default: defaults.user + opt :pass, 'Password', + short: 'p', type: :string, default: defaults.pass + opt :prefetch, 'Default prefetch count', + short: :none, type: :int, default: defaults.prefetch + + banner '' + + opt :log_target, 'Log target, file path or STDOUT', + short: 't', type: :string, default: defaults.log_target + opt :log_level, 'Log level (debug, info, warn, error, fatal)', + short: 'l', type: :string, default: defaults.log_level + opt :bunny_log_target, 'Log target used by Bunny', + short: :none, type: :string, default: defaults.bunny_log_target + opt :bunny_log_level, 'Log level used by Bunny', + short: :none, type: :string, default: defaults.bunny_log_level + + conflicts :url, :host + conflicts :url, :port + conflicts :url, :ssl + conflicts :url, :vhost + conflicts :url, :user + conflicts :url, :pass + + banner '' + end + end + end + + def url + @url ||= nil + end + attr_writer :url + + def host + @host ||= '127.0.0.1' + end + attr_writer :host + + def port + @port ||= 5672 + end + attr_writer :port + + def ssl + @ssl ||= false + end + attr_writer :ssl + + def vhost + @vhost ||= '/' + end + attr_writer :vhost + + def user + @user ||= 'guest' + end + attr_writer :user + + def pass + @pass ||= 'guest' + end + attr_writer :pass + + def prefetch + @prefetch ||= 1 + end + attr_writer :prefetch + + def log_target + @log_target ||= 'STDOUT' + end + attr_writer :log_target + + def log_level + @log_level ||= 'info' + end + attr_writer :log_level + + def bunny_log_target + @bunny_log_target ||= 'STDOUT' + end + attr_writer :bunny_log_target + + def bunny_log_level + @bunny_log_level ||= 'warn' + end + attr_writer :bunny_log_level + + def paths + @paths ||= [] + end + attr_writer :paths + end +end diff --git a/lib/bunnyrun/runner.rb b/lib/bunnyrun/runner.rb new file mode 100644 index 0000000..553de7f --- /dev/null +++ b/lib/bunnyrun/runner.rb @@ -0,0 +1,85 @@ +require 'bunny' +require 'logger' + +require 'bunnyrun/consumer' + +module BunnyRun + class Runner + attr_reader :options + attr_reader :consumer_classes + + def initialize(options = {}, consumer_classes) + @options = options + @consumer_classes = consumer_classes + end + + def run + consumer_classes.each do |consumer_class| + launch_consumer(consumer_class) + end + + block + end + + def connection + @connection ||= begin + conn = Bunny.new(connection_opts) + conn.start + conn + end + end + + def publish_channel + @publish_channel ||= connection.create_channel + end + + def logger + @logger ||= begin + logger = Logger.new(log_target) + logger.level = log_level + logger + end + end + + private + + def block + loop { sleep 1 } + end + + def launch_consumer(consumer_class) + consumer = consumer_class.new( + connection: connection, + publish_channel: publish_channel, + default_prefetch: options.prefetch, + logger: logger + ) + consumer.start + end + + def connection_opts + return options.url if options.url + + { + host: options.host, + port: options.port, + ssl: options.ssl, + vhost: options.vhost, + user: options.user, + pass: options.pass + } + end + + def log_target + if options.log_target.casecmp('stdout').zero? + STDOUT + else + options.log_target + end + end + + def log_level + Kernel.const_get("::Logger::#{options.log_level.upcase}") + end + end +end diff --git a/lib/bunnyrun/version.rb b/lib/bunnyrun/version.rb new file mode 100644 index 0000000..0407877 --- /dev/null +++ b/lib/bunnyrun/version.rb @@ -0,0 +1,3 @@ +module BunnyRun + VERSION = '0.1.0'.freeze +end diff --git a/spec/bunnyrun_spec.rb b/spec/bunnyrun_spec.rb new file mode 100644 index 0000000..2e28654 --- /dev/null +++ b/spec/bunnyrun_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +RSpec.describe Bunnyrun do + it 'has a version number' do + expect(Bunnyrun::VERSION).not_to be nil + end + + it 'does something useful' do + expect(false).to eq(true) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..7343800 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,11 @@ +require 'bundler/setup' +require 'bunnyrun' + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end