diff --git a/lib/rackstash/adapters.rb b/lib/rackstash/adapters.rb new file mode 100644 index 0000000..5abbb3b --- /dev/null +++ b/lib/rackstash/adapters.rb @@ -0,0 +1,145 @@ +# Copyright 2017 Holger Just +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE.txt file for details. + +require 'uri' + +module Rackstash + module Adapters + class << self + # Register a concrete adapter class which can be instanciated with a + # certain log device (e.g. a file name, an IO object, a URL specifying a + # log target, ...). With the provided `matchers`, a class can describe + # which kind of log devices are suitable to be used with it: + # + # * `String` with only lower-case characters - When passing a string with + # only lower-case characters, we register it as the scheme of a URL. + # When retrieving an adapter for a URL in {[]}, we check if the URL's + # scheme matches this string. + # * `String` with other characters - When passing a string that doesn't + # look like a URL scheme, we assume it to represent a class name. When + # retrieving a matching adapter for a device, we check if the name of + # the device's class matches this string. This can be used to register + # an adapter for a device which might not be loaded (yet), e.g. from an + # external gem. If possible, you should register the adapter for the + # actual class instead of its name. + # * `Symbol` - When passing a symbol, we check if the resolved device + # responds to an equally named method. + # * An object responding to the `===` method - When retrieving an adapter + # for a device, we are comparing the matcher object to the device. This + # is the same comparison as done in a `case ... when` statement in Ruby. + # Usually, the matcher object is either a class or module (in which case + # we check if the device object inherits from the matcher) or a proc, + # accepting an object as its first parameter. When checking this + # matcher, the proc gets called with the device as its parameter. If the + # proc returns a truethy value, we use it to build the adapter instance. + # + # @param adapter_class [Class] a concrete adapter class + # @param matchers [Array] a list of specifications + # for log devices the `adapter_class` can forward logs to. + # @raise [TypeError] if the passed adapter_class is not a class + # inheriting from {Adapters::Adapter} + # @return [Class] the `adapter_class` + def register(adapter_class, *matchers) + unless adapter_class.is_a?(Class) && adapter_class < Adapters::Adapter + raise TypeError, 'adapter_class must be a class and inherit from ' + + 'Rackstash::Adapters::Adapter' + end + + matchers.flatten.each do |matcher| + case matcher + when String + matcher = matcher.to_s + if matcher =~ /\A[a-z0-9]+\z/ + # If the matcher is a lower-case string, we assume it is a URL + # scheme. + adapter_schemes[matcher.downcase] = adapter_class + else + # If it starts with a upper-case characters, we assume it is a + # class name. + + # Since we use `compare_by_identity` for types, we need to ensure + # that we can override existing class names. + adapter_types.delete_if { |key, _value| key == matcher } + adapter_types[matcher] = adapter_class + end + when Symbol, ->(o) { o.respond_to?(:===) } + adapter_types[matcher] = adapter_class + else + # Should not be reached by "normal" objects since `Object` already + # responds to `===` (which is the same as `==` by default) + raise TypeError, "unknown matcher: #{matcher.inspect}" + end + end + + adapter_class + end + + # Try to build an adapter instance from the passed `device`. If the + # `device` is already an {Adapter}, it is returned unchanged. If not, we + # attempt to identify a suitable adapter class from the {register}ed + # classes and return a new adapter instance. + # + # if no suitable adapter can be found, we raise an `ArgumentError`. + # + # @param device [Adapters::Adapter, Object] a log device which should be + # wrapped in an {Adapter}. If it is already an adapter, the `device` is + # returned unchanged. + # @raise [ArgumentError] if no suitable adapter could be found for the + # provided `device` + # @return [Adapters::Adapter] the resolved adapter instance + def [](device) + return device if device.is_a?(Adapters::Adapter) + + adapter = adapter_by_uri(device) + adapter ||= adapter_by_type(device) + + unless adapter + raise ArgumentError, "No log adapter found for #{device.inspect}" + end + adapter + end + + private + + def adapter_by_uri(uri) + uri = URI(uri) rescue return + scheme = uri.scheme || uri.opaque + + return unless scheme + adapter_class = adapter_schemes.fetch(scheme.to_s.downcase) { return } + + if adapter_class.respond_to?(:from_uri) + adapter_class.from_uri(uri) + else + adapter_class.new(uri) + end + end + + def adapter_by_type(device) + adapter_types.each do |type, adapter_class| + suitable = + if type.is_a?(Symbol) + device.respond_to?(type) + elsif type.is_a?(String) + device.class.ancestors.any? { |klass| type == klass.name } + else + type === device + end + + return adapter_class.new(device) if suitable + end + nil + end + + def adapter_schemes + @adapter_schemes ||= {} + end + + def adapter_types + @adapter_types ||= {}.compare_by_identity + end + end + end +end diff --git a/lib/rackstash/adapters/adapter.rb b/lib/rackstash/adapters/adapter.rb new file mode 100644 index 0000000..36b1bdd --- /dev/null +++ b/lib/rackstash/adapters/adapter.rb @@ -0,0 +1,99 @@ +# Copyright 2017 Holger Just +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE.txt file for details. + +require 'rackstash/adapters' +require 'rackstash/encoders/json' + +module Rackstash + module Adapters + # The Adapter wraps a raw external log device like a file, an IO object like + # `STDOUT`, the system's syslog or even the connection to a TCP server with + # a common interface. At the end of a {Flow}, it is responsible to finally + # store the filtered and encoded log event. + # + # Each concrete adapter can register itself so that it can be used to wrap + # any compatible log device with {Rackstash::Adapters.[]}. + # + # @abstract Subclasses need to override at least {#write_single} to + # implement a concrete log adapter. + class Adapter + # Register the current class as an adapter for the provided matchers. + # + # This is a convenience method intended to be used by sub-classes of this + # abstract parent class to register themselves as adapters. + # + # @param matchers [Array] a list of specifications + # for log devices the current adapter can forward logs to. + # @return [self] + # @see Adapter.register + def self.register_for(*matchers) + Rackstash::Adapters.register(self, *matchers) + end + + # Create a new adapter instance. + # + # Usually, this method is overwritten by child classes to accept a + # suitable log device which will be used to write log lines to. When + # registering the adapter class, {Rackstash::Adapters.[]} will call + # {initialize} with a single argument: the log device. + def initialize(*) + end + + # Close the underlying log device if supported by it. + # + # This method needs to be overwritten in child classes. By default, this + # method does nothing. + # + # @return [void] + def close + end + + # Close and re-open the underlying log device if supported by it. + # + # This method needs to be overwritten in child classes. By default, this + # method does nothing. + # + # @return [void] + def reopen + end + + # Write a log line to the log device. This method is called by the flow + # with a formatted log event. + # + # @param log [Object] the encoded log event + # @return [nil] + def write(log) + write_single(log) + nil + end + + # Write a single log line to the log device. + # + # This method needs to be overwritten in child classes to write the + # encoded log event to the adapter's device. By default, this method + # raises a `NotImplementedError`. + # + # @param log [Object] the encoded log event + # @return [void] + # @raise NotImplementedError + def write_single(log) + raise NotImplementedError, 'write needs to be implemented in a sub class' + end + + private + + # Helper method to ensure that a log line passed to {#write} is a string + # that ends in a newline character by mutating the object is required. + # + # @param line [#to_s] a log line + # @return [String] a string with a trailing newline character (`"\n"`) + def normalize_line(line) + line = line.to_s + line << "\n".freeze unless line.end_with?("\n".freeze) + line + end + end + end +end diff --git a/spec/rackstash/adapters/adapter_spec.rb b/spec/rackstash/adapters/adapter_spec.rb new file mode 100644 index 0000000..7833d5c --- /dev/null +++ b/spec/rackstash/adapters/adapter_spec.rb @@ -0,0 +1,45 @@ +# Copyright 2017 Holger Just +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE.txt file for details. + +require 'spec_helper' + +require 'rackstash/adapters/adapter' + +describe Rackstash::Adapters::Adapter do + let(:adapter) { Rackstash::Adapters::Adapter.new } + + describe '#initialize' do + it 'accepts any arguments' do + Rackstash::Adapters::Adapter.new + Rackstash::Adapters::Adapter.new(:foo) + Rackstash::Adapters::Adapter.new(123, [:foo]) + end + end + + describe '#close' do + it 'does nothing' do + expect(adapter.close).to be nil + end + end + + describe '#reopen' do + it 'does nothing' do + expect(adapter.reopen).to be nil + end + end + + describe '#write' do + it 'calls write_single' do + expect(adapter).to receive(:write_single).with('a log line') + adapter.write('a log line') + end + end + + describe '#write_single' do + it 'is not implemented in the abstract base class' do + expect { adapter.write('something') }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/rackstash/adapters_spec.rb b/spec/rackstash/adapters_spec.rb new file mode 100644 index 0000000..1594308 --- /dev/null +++ b/spec/rackstash/adapters_spec.rb @@ -0,0 +1,258 @@ +# Copyright 2017 Holger Just +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE.txt file for details. + +require 'spec_helper' + +require 'rackstash/adapters' + +describe Rackstash::Adapters do + around(:each) do |example| + types = Rackstash::Adapters.send(:adapter_types) + schemes = Rackstash::Adapters.send(:adapter_schemes) + + Rackstash::Adapters.instance_variable_set(:@adapter_types, nil) + Rackstash::Adapters.instance_variable_set(:@adapter_schemes, nil) + + example.run + + Rackstash::Adapters.instance_variable_set(:@adapter_types, types) + Rackstash::Adapters.instance_variable_set(:@adapter_schemes, schemes) + end + + let(:adapter) { + Class.new(Rackstash::Adapters::Adapter) do + def self.from_uri(*args) + new(*args) + end + end + } + + describe '#register' do + it 'can register a class' do + expect { + Rackstash::Adapters.register adapter, Class.new + Rackstash::Adapters.register adapter, String + Rackstash::Adapters.register adapter, Numeric + Rackstash::Adapters.register adapter, Integer + }.to change { Rackstash::Adapters.send(:adapter_types).size } + .from(0).to(4) + expect(Rackstash::Adapters.send(:adapter_schemes).size).to eql 0 + end + + it 'can register a class name (upper-case String)' do + expect { + Rackstash::Adapters.register adapter, '❤' + Rackstash::Adapters.register adapter, '' + Rackstash::Adapters.register adapter, 'Hello::World' + }.to change { Rackstash::Adapters.send(:adapter_types).size } + .from(0).to(3) + + # Registering 'Hello::World' a second time overwrites the first one + expect { + Rackstash::Adapters.register(adapter, 'Hello::World') + }.not_to change { Rackstash::Adapters.send(:adapter_types).size } + + expect(Rackstash::Adapters.send(:adapter_schemes).size).to eql 0 + end + + it 'can register a method name (symbol)' do + expect { + Rackstash::Adapters.register adapter, :foo + }.to change { Rackstash::Adapters.send(:adapter_types).size } + .from(0).to(1) + expect(Rackstash::Adapters.send(:adapter_schemes).size).to eql 0 + end + + it 'can register a proc' do + expect { + Rackstash::Adapters.register adapter, ->(o) { o.respond_to?(:write) } + Rackstash::Adapters.register adapter, -> {} + }.to change { Rackstash::Adapters.send(:adapter_types).size } + .from(0).to(2) + expect(Rackstash::Adapters.send(:adapter_schemes).size).to eql 0 + end + + it 'can register a scheme (lower-case String)' do + expect { + Rackstash::Adapters.register adapter, 'customscheme' + }.to change { Rackstash::Adapters.send(:adapter_schemes).size } + .from(0).to(1) + expect(Rackstash::Adapters.send(:adapter_types).size).to eql 0 + end + + it 'rejects invalid adapter classes' do + expect { Rackstash::Adapters.register nil, :foo } + .to raise_error(TypeError) + expect { Rackstash::Adapters.register Class.new, :foo } + .to raise_error(TypeError) + end + end + + describe '#[]' do + context 'with a registered class' do + let(:device_class) { Class.new } + + before do + Rackstash::Adapters.register adapter, device_class + end + + it 'creates an adapter if the class was found' do + device = device_class.new + + expect(device_class).to receive(:===).with(device).and_call_original + expect(adapter).to receive(:new).with(device).and_call_original + expect(Rackstash::Adapters[device]).to be_an Rackstash::Adapters::Adapter + end + + it 'creates an adapter if any parent class was found' do + inherited_device = Class.new(device_class).new + + expect(device_class).to receive(:===).with(inherited_device).and_call_original + expect(adapter).to receive(:new).with(inherited_device).and_call_original + expect(Rackstash::Adapters[inherited_device]).to be_an Rackstash::Adapters::Adapter + end + + it 'raises if no class was found' do + expect(adapter).to_not receive(:new) + expect { Rackstash::Adapters['foo'] }.to raise_error(ArgumentError) + end + end + + context 'with a registered class name' do + before do + class SpecDevice; end + class InheritedSpecDevice < SpecDevice; end + + Rackstash::Adapters.register adapter, 'SpecDevice' + end + + after do + Object.send :remove_const, :InheritedSpecDevice + Object.send :remove_const, :SpecDevice + end + + it 'creates an adapter if the class was found' do + device = SpecDevice.new + + expect(adapter).to receive(:new).with(device).and_call_original + expect(Rackstash::Adapters[device]).to be_an Rackstash::Adapters::Adapter + end + + it 'creates an adapter if any parent class was found' do + inherited_device = InheritedSpecDevice.new + + expect(adapter).to receive(:new).with(inherited_device).and_call_original + expect(Rackstash::Adapters[inherited_device]).to be_an Rackstash::Adapters::Adapter + end + + it 'raises if no class was found' do + expect(adapter).to_not receive(:new) + expect { Rackstash::Adapters['foo'] }.to raise_error(ArgumentError) + end + end + + context 'with a registered symbol' do + before do + Rackstash::Adapters.register adapter, :foo + end + + it 'creates an adapter if it responds to the registered method' do + device = Struct.new(:foo).new('foo') + + expect(adapter).to receive(:new).with(device).and_call_original + expect(Rackstash::Adapters[device]).to be_an Rackstash::Adapters::Adapter + end + + it 'raises if it does not respond to the registered method' do + device = Struct.new(:bar).new('bar') + + expect(adapter).to_not receive(:new) + expect { Rackstash::Adapters[device] }.to raise_error(ArgumentError) + end + end + + context 'with a registered proc' do + let(:device) { Object.new } + + it 'creates an adapter if the proc returns true' do + checker = proc { true } + Rackstash::Adapters.register adapter, checker + + expect(checker).to receive(:===).with(device).and_call_original + expect(adapter).to receive(:new).with(device).and_call_original + expect(Rackstash::Adapters[device]).to be_an Rackstash::Adapters::Adapter + end + + it 'does not create an adapter if the proc returns false' do + checker = proc { false } + Rackstash::Adapters.register adapter, checker + + expect(checker).to receive(:===).with(device).and_call_original + expect(adapter).to_not receive(:new) + expect { Rackstash::Adapters[device] }.to raise_error(ArgumentError) + end + end + + context 'with a registered scheme' do + before do + Rackstash::Adapters.register adapter, 'dummy' + end + + it 'creates an adapter from the scheme' do + raw_uri = 'dummy://example.com' + expect(adapter).to receive(:from_uri).with(URI(raw_uri)).and_call_original + expect(Rackstash::Adapters[raw_uri]).to be_an Rackstash::Adapters::Adapter + end + + it 'creates an adapter from a URI' do + uri = URI('dummy://example.com') + expect(adapter).to receive(:from_uri).with(uri).and_call_original + expect(Rackstash::Adapters[uri]).to be_an Rackstash::Adapters::Adapter + end + + it 'raises if no scheme was found' do + expect(adapter).to_not receive(:new) + expect(adapter).to_not receive(:from_uri) + expect { Rackstash::Adapters['unknown://example.com'] } + .to raise_error(ArgumentError) + expect { Rackstash::Adapters[URI('unknown://example.com')] } + .to raise_error(ArgumentError) + end + + context 'and a registered class' do + before do + Rackstash::Adapters.register adapter, Object + end + + it 'falls though on invalid URI' do + invalid_uri = '::' + + expect(adapter).to_not receive(:from_uri) + # from the fallback + expect(adapter).to receive(:new).with(invalid_uri).and_call_original + expect(Rackstash::Adapters[invalid_uri]).to be_an Rackstash::Adapters::Adapter + end + + it 'falls though if no scheme was found' do + unknown_uri = 'unknown://example.com' + + expect(adapter).to_not receive(:from_uri) + expect(adapter).to receive(:new).with(unknown_uri).and_call_original + expect(Rackstash::Adapters[unknown_uri]).to be_an Rackstash::Adapters::Adapter + end + end + end + + context 'with an existing adapter object' do + it 'just returns the object' do + adapter_instance = adapter.new + Rackstash::Adapters.register adapter, Object + + expect(adapter).to_not receive(:new) + expect(Rackstash::Adapters[adapter_instance]).to equal adapter_instance + end + end + end +end