From 85ae18d6dcb2f9b82ec410ea8e9bbcdd817f6c98 Mon Sep 17 00:00:00 2001 From: Ahmad Elassuty Date: Wed, 20 Jan 2021 00:32:45 +0100 Subject: [PATCH] feat: add sync and sidekiq delivery adapters (#7) * feat: add memory and sidekiq delivery adapters --- .github/workflows/specs.yaml | 2 +- Gemfile.lock | 9 +- bin/console | 5 +- event_router.gemspec | 3 +- examples/common.rb | 6 ++ examples/event_store/order_placed.rb | 15 +++ examples/notifications.rb | 22 +++++ examples/order_placed.rb | 21 ++++ examples/payment_received.rb | 8 ++ sidekiq.yml => examples/sidekiq/config.yml | 2 +- examples/sidekiq/run_console.rb | 15 +++ examples/sidekiq/run_server.rb | 11 +++ examples/sync/run_console.rb | 13 +++ lib/event_router.rb | 37 +++---- lib/event_router/configuration.rb | 68 ++++++++----- lib/event_router/deliver_event_job.rb | 18 ---- lib/event_router/delivery_adapters/base.rb | 27 +++++ .../jobs/sidekiq_event_delivery_job.rb | 29 ++++++ lib/event_router/delivery_adapters/sidekiq.rb | 36 +++++++ lib/event_router/delivery_adapters/sync.rb | 15 +++ lib/event_router/destination.rb | 8 +- .../errors/required_option_error.rb | 20 ++++ .../errors/unsupported_option_error.rb | 3 +- lib/event_router/event.rb | 12 ++- lib/event_router/event_serializer.rb | 29 ------ lib/event_router/publisher.rb | 24 +++++ lib/event_router/serializer.rb | 26 +++++ lib/event_router/serializers/base.rb | 17 ++++ lib/event_router/serializers/json.rb | 38 +++++++ lib/event_router/serializers/oj.rb | 19 ++++ lib/examples/event_store/order_placed.rb | 13 --- lib/examples/notifications.rb | 17 ---- lib/examples/order_placed.rb | 30 ------ lib/examples/payment_received.rb | 10 -- spec/event_router/configuration_spec.rb | 98 ++++++++++++++----- .../delivery_adapters/sync_spec.rb | 26 +++++ spec/event_router/destination_spec.rb | 84 ++++++++++++++++ spec/event_router/event_spec.rb | 39 ++++++++ spec/event_router/publisher_spec.rb | 36 +++++++ spec/event_router/serializer_spec.rb | 53 ++++++++++ spec/event_router/serializers/json_spec.rb | 20 ++++ spec/event_router/serializers/oj_spec.rb | 18 ++++ spec/event_router_spec.rb | 39 ++++++-- spec/spec_helper.rb | 4 + spec/test_adapters.rb | 2 + spec/test_events.rb | 18 ++++ 46 files changed, 847 insertions(+), 218 deletions(-) create mode 100644 examples/common.rb create mode 100644 examples/event_store/order_placed.rb create mode 100644 examples/notifications.rb create mode 100644 examples/order_placed.rb create mode 100644 examples/payment_received.rb rename sidekiq.yml => examples/sidekiq/config.yml (53%) create mode 100644 examples/sidekiq/run_console.rb create mode 100644 examples/sidekiq/run_server.rb create mode 100644 examples/sync/run_console.rb delete mode 100644 lib/event_router/deliver_event_job.rb create mode 100644 lib/event_router/delivery_adapters/base.rb create mode 100644 lib/event_router/delivery_adapters/jobs/sidekiq_event_delivery_job.rb create mode 100644 lib/event_router/delivery_adapters/sidekiq.rb create mode 100644 lib/event_router/delivery_adapters/sync.rb create mode 100644 lib/event_router/errors/required_option_error.rb delete mode 100644 lib/event_router/event_serializer.rb create mode 100644 lib/event_router/publisher.rb create mode 100644 lib/event_router/serializer.rb create mode 100644 lib/event_router/serializers/base.rb create mode 100644 lib/event_router/serializers/json.rb create mode 100644 lib/event_router/serializers/oj.rb delete mode 100644 lib/examples/event_store/order_placed.rb delete mode 100644 lib/examples/notifications.rb delete mode 100644 lib/examples/order_placed.rb delete mode 100644 lib/examples/payment_received.rb create mode 100644 spec/event_router/delivery_adapters/sync_spec.rb create mode 100644 spec/event_router/destination_spec.rb create mode 100644 spec/event_router/event_spec.rb create mode 100644 spec/event_router/publisher_spec.rb create mode 100644 spec/event_router/serializer_spec.rb create mode 100644 spec/event_router/serializers/json_spec.rb create mode 100644 spec/event_router/serializers/oj_spec.rb create mode 100644 spec/test_adapters.rb create mode 100644 spec/test_events.rb diff --git a/.github/workflows/specs.yaml b/.github/workflows/specs.yaml index be99f2e..a5dc313 100644 --- a/.github/workflows/specs.yaml +++ b/.github/workflows/specs.yaml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos] - ruby: [2.5, 2.6, 2.7, head] + ruby: [2.5, 2.6, 2.7] steps: - uses: actions/checkout@v2 diff --git a/Gemfile.lock b/Gemfile.lock index 3c10252..aec88e8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,14 +2,11 @@ PATH remote: . specs: event_router (0.1.0) - activejob + activesupport GEM remote: https://rubygems.org/ specs: - activejob (6.0.3.2) - activesupport (= 6.0.3.2) - globalid (>= 0.3.6) activesupport (6.0.3.2) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) @@ -22,12 +19,11 @@ GEM concurrent-ruby (1.1.7) connection_pool (2.2.3) diff-lcs (1.3) - globalid (0.4.2) - activesupport (>= 4.2.0) i18n (1.8.5) concurrent-ruby (~> 1.0) method_source (1.0.0) minitest (5.14.1) + oj (3.10.14) parallel (1.19.2) parser (2.7.1.4) ast (~> 2.4.1) @@ -82,6 +78,7 @@ PLATFORMS DEPENDENCIES event_router! + oj pry pry-byebug rspec (~> 3.0) diff --git a/bin/console b/bin/console index 87a0117..91ff995 100755 --- a/bin/console +++ b/bin/console @@ -7,8 +7,5 @@ require 'event_router' # 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 'pry' -Pry.start(__FILE__) +Pry.start diff --git a/event_router.gemspec b/event_router.gemspec index e9c13da..1921ac5 100644 --- a/event_router.gemspec +++ b/event_router.gemspec @@ -28,9 +28,10 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] # Dependencies - spec.add_dependency 'activejob' + spec.add_dependency 'activesupport' # Development dependencies + spec.add_development_dependency 'oj' spec.add_development_dependency 'pry' spec.add_development_dependency 'pry-byebug' spec.add_development_dependency 'rspec', '~> 3.0' diff --git a/examples/common.rb b/examples/common.rb new file mode 100644 index 0000000..1a17a1d --- /dev/null +++ b/examples/common.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require_relative 'event_store/order_placed' +require_relative 'notifications' +require_relative 'payment_received' +require_relative 'order_placed' diff --git a/examples/event_store/order_placed.rb b/examples/event_store/order_placed.rb new file mode 100644 index 0000000..68e442b --- /dev/null +++ b/examples/event_store/order_placed.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Examples + module EventStore + class OrderPlaced + def self.handle(event:, payload:) + puts "#{'=' * 10} [EventStore] #{'=' * 10}" + puts 'Received order_placed' + puts event.inspect + puts payload.inspect + puts '=' * 32 + end + end + end +end diff --git a/examples/notifications.rb b/examples/notifications.rb new file mode 100644 index 0000000..a592fa6 --- /dev/null +++ b/examples/notifications.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Examples + module Notifications + module_function + + def order_placed(event:, payload:) + puts "#{'=' * 10} [Notifications] #{'=' * 10}" + puts 'Received order_placed' + puts event.inspect + puts payload.inspect + puts '=' * 35 + end + + def payment_received(event:, **_payload) + puts "#{'=' * 10} [Notifications] #{'=' * 10}" + puts 'Received payment_received' + puts event.inspect + puts '=' * 35 + end + end +end diff --git a/examples/order_placed.rb b/examples/order_placed.rb new file mode 100644 index 0000000..01c2ee1 --- /dev/null +++ b/examples/order_placed.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Examples + class OrderPlaced < EventRouter::Event + deliver_to :notifications, + handler: Examples::Notifications + + deliver_to :event_store, + handler: Examples::EventStore::OrderPlaced, + handler_method: :handle, + prefetch_payload: true, + payload_method: :store_payload + + # Extra payload + def store_payload + { + id: SecureRandom.uuid + } + end + end +end diff --git a/examples/payment_received.rb b/examples/payment_received.rb new file mode 100644 index 0000000..a0fbfa9 --- /dev/null +++ b/examples/payment_received.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Examples + class PaymentReceived < EventRouter::Event + deliver_to :notifications, + handler: Examples::Notifications + end +end diff --git a/sidekiq.yml b/examples/sidekiq/config.yml similarity index 53% rename from sidekiq.yml rename to examples/sidekiq/config.yml index af83312..84860bb 100644 --- a/sidekiq.yml +++ b/examples/sidekiq/config.yml @@ -1,3 +1,3 @@ :concurrency: 1 :queues: - - [default, 1] + - [event_router, 1] diff --git a/examples/sidekiq/run_console.rb b/examples/sidekiq/run_console.rb new file mode 100644 index 0000000..b758466 --- /dev/null +++ b/examples/sidekiq/run_console.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'bundler/setup' +require 'event_router' +require 'pry' + +require_relative '../common' + +EventRouter.configure do |config| + config.register_delivery_adapter :sidekiq, queue: :event_router, retry: 5 + + config.delivery_adapter = :sidekiq +end + +Pry.start diff --git a/examples/sidekiq/run_server.rb b/examples/sidekiq/run_server.rb new file mode 100644 index 0000000..651208f --- /dev/null +++ b/examples/sidekiq/run_server.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'event_router' + +require_relative '../common' + +EventRouter.configure do |config| + config.register_delivery_adapter :sidekiq, queue: :event_router, retry: 5 + + config.delivery_adapter = :sidekiq +end diff --git a/examples/sync/run_console.rb b/examples/sync/run_console.rb new file mode 100644 index 0000000..aaa4a7d --- /dev/null +++ b/examples/sync/run_console.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'bundler/setup' +require 'event_router' + +require_relative '../common' + +EventRouter.configure do |config| + config.delivery_adapter = :sync +end + +require 'pry' +Pry.start diff --git a/lib/event_router.rb b/lib/event_router.rb index 1ff77aa..f1c98ca 100644 --- a/lib/event_router.rb +++ b/lib/event_router.rb @@ -2,38 +2,29 @@ require 'event_router/version' require 'event_router/error' -require 'active_job' -require 'event_router/configuration' -require 'event_router/destination' -require 'event_router/event' -require 'event_router/event_serializer' -require 'event_router/deliver_event_job' -require 'examples/notifications' -require 'examples/event_store/order_placed' -require 'examples/order_placed' -require 'examples/payment_received' - -require 'pry' +require 'event_router/event' +require 'event_router/delivery_adapters/base' +require 'event_router/serializers/base' +require 'event_router/publisher' +require 'event_router/serializer' +require 'event_router/configuration' module EventRouter module_function - def publish(*events) - correlation_id = events.first.correlation_id - - events.each do |event| - event.correlation_id = correlation_id + def publish(events, adapter: EventRouter.configuration.delivery_adapter) + EventRouter::Publisher.publish(events, adapter: adapter) + end - event.destinations.each do |name, destination| - payload = destination.prefetch_payload? ? destination.payload_for(event) : nil + def serialize(event, adapter: EventRouter.configuration.serializer_adapter) + EventRouter::Serializer.serialize(event, adapter: adapter) + end - DeliverEventJob.perform_later(name, event, payload) - end - end + def deserialize(payload, adapter: EventRouter.configuration.serializer_adapter) + EventRouter::Serializer.deserialize(payload, adapter: adapter) end - # Configurations def configuration @configuration ||= Configuration.new end diff --git a/lib/event_router/configuration.rb b/lib/event_router/configuration.rb index 50040b5..c61a9e4 100644 --- a/lib/event_router/configuration.rb +++ b/lib/event_router/configuration.rb @@ -4,47 +4,67 @@ module EventRouter class Configuration - attr_reader :delivery_adapter, :delivery_strategy + attr_reader :delivery_adapter, :delivery_adapters, + :serializer_adapter, :serializer_adapters - # Constants - DELIVERY_ADAPTERS = %i[ - memory - ].freeze - - DELIVERY_STRATEGIES = %i[ - async - sync - ].freeze + def initialize + @delivery_adapters = {} + @serializer_adapters = {} - DEFAULT_CONFIGURATIONS = { - delivery_adapter: :memory, - delivery_strategy: :async - }.freeze + register_delivery_adapter(:sync) + @delivery_adapter = :sync - def initialize - @delivery_adapter = DEFAULT_CONFIGURATIONS[:delivery_adapter] - @delivery_strategy = DEFAULT_CONFIGURATIONS[:delivery_strategy] + register_serializer_adapter(:json) + @serializer_adapter = :json end - def delivery_adapter=(adapter) - validate_inclusion(:delivery_adapter, adapter, DELIVERY_ADAPTERS) + def delivery_adapter=(adapter, _opts = {}) + validate_inclusion!(:delivery_adapter, adapter, @delivery_adapters) @delivery_adapter = adapter end - def delivery_strategy=(strategy) - validate_inclusion(:delivery_strategy, strategy, DELIVERY_STRATEGIES) + def delivery_adapter_class(adapter) + @delivery_adapters[adapter] + end + + def register_delivery_adapter(adapter, opts = {}) + adapter_class = load_adapter_class(Publisher::ADAPTERS, adapter) || opts[:adapter_class] + + adapter_class.options = opts + @delivery_adapters[adapter] = adapter_class + end + + def serializer_adapter=(adapter) + validate_inclusion!(:serializer_adapter, adapter, @serializer_adapters) - @delivery_strategy = strategy + @serializer_adapter = adapter + end + + def serializer_adapter_class(adapter) + @serializer_adapters[adapter] + end + + def register_serializer_adapter(adapter, opts = {}) + adapter_class = load_adapter_class(Serializer::ADAPTERS, adapter) || opts[:adapter_class] + + @serializer_adapters[adapter] = adapter_class end private - def validate_inclusion(config, option, supported_options) + def load_adapter_class(adapters, adapter) + return nil unless adapters.key?(adapter) + + require_relative adapters[adapter][:path] + Kernel.const_get(adapters[adapter][:adapter_class]) + end + + def validate_inclusion!(config, option, supported_options) return if supported_options.include?(option) raise Errors::UnsupportedOptionError.new( - config: config, option: option, supported_options: supported_options + config: config, option: option, supported_options: supported_options.keys ) end end diff --git a/lib/event_router/deliver_event_job.rb b/lib/event_router/deliver_event_job.rb deleted file mode 100644 index ab71910..0000000 --- a/lib/event_router/deliver_event_job.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -require 'pry' - -module EventRouter - class DeliverEventJob < ActiveJob::Base - self.queue_adapter = :sidekiq - - def perform(destination, event, payload) - destination = event.destinations[destination] - - return if destination.blank? - - payload ||= destination.payload_for(event) - destination.process(event, payload) - end - end -end diff --git a/lib/event_router/delivery_adapters/base.rb b/lib/event_router/delivery_adapters/base.rb new file mode 100644 index 0000000..76e07f1 --- /dev/null +++ b/lib/event_router/delivery_adapters/base.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative '../errors/required_option_error' + +module EventRouter + module DeliveryAdapters + class Base + class << self + attr_reader :options + + def options=(options) + validate_options!(options) + + @options = options + end + + def validate_options!(_options) + true + end + + def deliver(_event) + raise NotImplementedError, "deliver method is not implemented for #{name}" + end + end + end + end +end diff --git a/lib/event_router/delivery_adapters/jobs/sidekiq_event_delivery_job.rb b/lib/event_router/delivery_adapters/jobs/sidekiq_event_delivery_job.rb new file mode 100644 index 0000000..0a94264 --- /dev/null +++ b/lib/event_router/delivery_adapters/jobs/sidekiq_event_delivery_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative '../../serializer' +require 'sidekiq' + +module EventRouter + module DeliveryAdapters + module Jobs + class SidekiqEventDeliveryJob + include ::Sidekiq::Worker + + def perform(destination_name, serialized_event, serialized_payload) + event = EventRouter.deserialize(serialized_event) + destination = event.destinations[destination_name.to_sym] + + return unless destination + + payload = if destination.prefetch_payload? + EventRouter.deserialize(serialized_payload) + else + destination.extra_payload(event) + end + + destination.process(event, payload) + end + end + end + end +end diff --git a/lib/event_router/delivery_adapters/sidekiq.rb b/lib/event_router/delivery_adapters/sidekiq.rb new file mode 100644 index 0000000..8472d6d --- /dev/null +++ b/lib/event_router/delivery_adapters/sidekiq.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative 'jobs/sidekiq_event_delivery_job' + +module EventRouter + module DeliveryAdapters + class Sidekiq < Base + REQUIRED_OPTIONS = %i[queue retry].freeze + + class << self + def validate_options!(options) + missing_options = REQUIRED_OPTIONS - options.compact.keys + + return true if missing_options.empty? + + raise Errors::RequiredOptionError.new(options: missing_options, adapter: self) + end + + def deliver(event) + serialized_event = EventRouter.serialize(event) + + event.destinations.each do |name, destination| + if destination.prefetch_payload? + payload = destination.extra_payload(event) + serialized_payload = EventRouter.serialize(payload) + end + + Jobs::SidekiqEventDeliveryJob + .set(queue: options[:queue], retry: options[:retry]) + .perform_async(name, serialized_event, serialized_payload) + end + end + end + end + end +end diff --git a/lib/event_router/delivery_adapters/sync.rb b/lib/event_router/delivery_adapters/sync.rb new file mode 100644 index 0000000..22ca29d --- /dev/null +++ b/lib/event_router/delivery_adapters/sync.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module EventRouter + module DeliveryAdapters + class Sync < Base + def self.deliver(event) + event.destinations.each do |_name, destination| + payload = destination.extra_payload(event) + + destination.process(event, payload) + end + end + end + end +end diff --git a/lib/event_router/destination.rb b/lib/event_router/destination.rb index 518ef01..d24d6f3 100644 --- a/lib/event_router/destination.rb +++ b/lib/event_router/destination.rb @@ -39,14 +39,10 @@ def prefetch_payload? @prefetch_payload end - def payload_for(event) - return event.payload unless custom_payload?(event) + def extra_payload(event) + return nil unless event.respond_to?(payload_method) event.send(payload_method) end - - def custom_payload?(event) - event.respond_to?(payload_method) - end end end diff --git a/lib/event_router/errors/required_option_error.rb b/lib/event_router/errors/required_option_error.rb new file mode 100644 index 0000000..df7c77e --- /dev/null +++ b/lib/event_router/errors/required_option_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative '../error' + +module EventRouter + module Errors + class RequiredOptionError < Error + def initialize(message: nil, options:, adapter:) + @options = options + @adapter = adapter + + super(message || self.message) + end + + def message + "#{@options} are required for #{@adapter} adapter." + end + end + end +end diff --git a/lib/event_router/errors/unsupported_option_error.rb b/lib/event_router/errors/unsupported_option_error.rb index 3f29340..6f913dc 100644 --- a/lib/event_router/errors/unsupported_option_error.rb +++ b/lib/event_router/errors/unsupported_option_error.rb @@ -14,7 +14,8 @@ def initialize(message: nil, config:, option:, supported_options:) end def message - "Unsupported #{@option} for #{@config} configuration. Currently supports #{@supported_options}" + "Unsupported #{@option} for #{@config} configuration. Currently supports #{@supported_options}. " \ + 'Please consider registering the adapter before referencing it.' end end end diff --git a/lib/event_router/event.rb b/lib/event_router/event.rb index e21692a..0eed8d7 100644 --- a/lib/event_router/event.rb +++ b/lib/event_router/event.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true require 'securerandom' +require 'active_support/core_ext/class/attribute' +require 'active_support/core_ext/string/inflections' + +require_relative 'destination' module EventRouter class Event @@ -9,7 +13,7 @@ class Event class_attribute :destinations, default: {}, instance_writer: false - def initialize(uid: SecureRandom.uuid, correlation_id: SecureRandom.uuid, created_at: Time.current, **payload) + def initialize(uid: SecureRandom.uuid, correlation_id: SecureRandom.uuid, created_at: Time.now, **payload) @uid = uid @correlation_id = correlation_id @created_at = created_at @@ -20,13 +24,13 @@ def to_hash { uid: uid, correlation_id: correlation_id, - name: name, payload: payload, - created_at: created_at, - _er_klass: self.class.name + created_at: created_at } end + alias to_h to_hash + def name self.class.name.demodulize.underscore end diff --git a/lib/event_router/event_serializer.rb b/lib/event_router/event_serializer.rb deleted file mode 100644 index 7b008f2..0000000 --- a/lib/event_router/event_serializer.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module EventRouter - class EventSerializer < ActiveJob::Serializers::ObjectSerializer - def serialize(event) - event_attrs = event.to_hash - payload = event_attrs.delete(:payload) - serialized_payload = ActiveJob::Arguments.serialize(payload) - serialized_event = super(event_attrs) - - serialized_event.merge(payload: serialized_payload) - end - - def deserialize(hash) - hash['_er_klass'].constantize.new( - uid: hash['uid'], - correlation_id: hash['correlation_id'], - created_at: Time.parse(hash['created_at']), - **ActiveJob::Arguments.deserialize(hash['payload']).to_h - ) - end - - private - - def klass - Event - end - end -end diff --git a/lib/event_router/publisher.rb b/lib/event_router/publisher.rb new file mode 100644 index 0000000..2c70dfd --- /dev/null +++ b/lib/event_router/publisher.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module EventRouter + module Publisher + module_function + + ADAPTERS = { + sync: { adapter_class: 'EventRouter::DeliveryAdapters::Sync', path: 'delivery_adapters/sync' }, + sidekiq: { adapter_class: 'EventRouter::DeliveryAdapters::Sidekiq', path: 'delivery_adapters/sidekiq' } + }.freeze + + def publish(events, adapter:) + adapter_class = delivery_adapter(adapter) + + Array(events).each { |event| adapter_class.deliver(event) } + end + + def delivery_adapter(adapter) + EventRouter.configuration.delivery_adapter_class(adapter) + end + + private_class_method :delivery_adapter + end +end diff --git a/lib/event_router/serializer.rb b/lib/event_router/serializer.rb new file mode 100644 index 0000000..1341520 --- /dev/null +++ b/lib/event_router/serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module EventRouter + module Serializer + module_function + + ADAPTERS = { + json: { adapter_class: 'EventRouter::Serializers::Json', path: 'serializers/json' }, + oj: { adapter_class: 'EventRouter::Serializers::Oj', path: 'serializers/oj' } + }.freeze + + def serialize(payload, adapter:) + serializer_adapter(adapter).serialize(payload) + end + + def deserialize(payload, adapter:) + serializer_adapter(adapter).deserialize(payload) + end + + def serializer_adapter(adapter) + EventRouter.configuration.serializer_adapter_class(adapter) + end + + private_class_method :serializer_adapter + end +end diff --git a/lib/event_router/serializers/base.rb b/lib/event_router/serializers/base.rb new file mode 100644 index 0000000..160151b --- /dev/null +++ b/lib/event_router/serializers/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module EventRouter + module Serializers + class Base + class << self + def serialize(_object) + raise NotImplementedError, 'Sub-classes must implement serialize method.' + end + + def deserialize(_string) + raise NotImplementedError, 'Sub-classes must implement serialize method.' + end + end + end + end +end diff --git a/lib/event_router/serializers/json.rb b/lib/event_router/serializers/json.rb new file mode 100644 index 0000000..d403ab4 --- /dev/null +++ b/lib/event_router/serializers/json.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'json' + +module EventRouter + module Serializers + class Json < Base + EVENT_CLASS_ATTRIBUTE_NAME = 'er_class' + + class << self + def serialize(object) + return JSON.generate(object) unless object.is_a?(EventRouter::Event) + + attributes = object.to_h + attributes[EVENT_CLASS_ATTRIBUTE_NAME] = object.class.name + + JSON.generate(attributes) + end + + def deserialize(payload) + object = JSON.parse(payload) + + return object unless object.is_a?(Hash) + return object unless object.key?(EVENT_CLASS_ATTRIBUTE_NAME) + + event_class = object.delete(EVENT_CLASS_ATTRIBUTE_NAME) + event_instance = const_get(event_class).new + + object.each do |attribute, value| + event_instance.instance_variable_set(:"@#{attribute}", value) + end + + event_instance + end + end + end + end +end diff --git a/lib/event_router/serializers/oj.rb b/lib/event_router/serializers/oj.rb new file mode 100644 index 0000000..681bb1f --- /dev/null +++ b/lib/event_router/serializers/oj.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'oj' + +module EventRouter + module Serializers + class Oj < Base + class << self + def serialize(object) + ::Oj.dump(object, mode: :object) + end + + def deserialize(string) + ::Oj.load(string, mode: :object) + end + end + end + end +end diff --git a/lib/examples/event_store/order_placed.rb b/lib/examples/event_store/order_placed.rb deleted file mode 100644 index de62a00..0000000 --- a/lib/examples/event_store/order_placed.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module EventRouter - module Examples - module EventStore - class OrderPlaced - def self.handle(_event) - true - end - end - end - end -end diff --git a/lib/examples/notifications.rb b/lib/examples/notifications.rb deleted file mode 100644 index 3c77daa..0000000 --- a/lib/examples/notifications.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module EventRouter - module Examples - module Notifications - module_function - - def order_placed(_args) - true - end - - def payment_received(_event) - true - end - end - end -end diff --git a/lib/examples/order_placed.rb b/lib/examples/order_placed.rb deleted file mode 100644 index 0dde787..0000000 --- a/lib/examples/order_placed.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module EventRouter - module Examples - class OrderPlaced < EventRouter::Event - deliver_to :notifications, - handler: EventRouter::Examples::Notifications - - deliver_to :event_store, - handler: EventRouter::Examples::EventStore::OrderPlaced, - handler_method: :handle, - prefetch_payload: true, - payload_method: :store_payload - - # Custom payload methods - def notifications_payload - { - id: payload[:order_id], - notification_name: :first_order - } - end - - def store_payload - { - id: payload[:order_id] - } - end - end - end -end diff --git a/lib/examples/payment_received.rb b/lib/examples/payment_received.rb deleted file mode 100644 index 95d06eb..0000000 --- a/lib/examples/payment_received.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module EventRouter - module Examples - class PaymentReceived < EventRouter::Event - deliver_to :notifications, - handler: EventRouter::Examples::Notifications - end - end -end diff --git a/spec/event_router/configuration_spec.rb b/spec/event_router/configuration_spec.rb index 0fe4ff0..4d7bfed 100644 --- a/spec/event_router/configuration_spec.rb +++ b/spec/event_router/configuration_spec.rb @@ -1,53 +1,103 @@ RSpec.describe EventRouter::Configuration do - subject(:config) { described_class.new } + let(:config) { described_class.new } describe '.new' do - it 'initializes with memory delivery adapter by default' do - expect(subject.delivery_adapter).to eq(:memory) + subject { config } + + it 'initializes with sync delivery adapter by default' do + expect(subject.delivery_adapter).to eq(:sync) end - it 'initializes with async delivery strategy by default' do - expect(subject.delivery_strategy).to eq(:async) + it 'initializes with json serialization adapter by default' do + expect(subject.serializer_adapter).to eq(:json) end end describe '#delivery_adapter=' do - EventRouter::Configuration::DELIVERY_ADAPTERS.each do |adapter| - it "supports #{adapter} adapter" do - expect { config.delivery_adapter = adapter }.to_not raise_error - end + subject { config.delivery_adapter = :test_adapter } - it 'updates the configuration' do - config.delivery_adapter = adapter + context 'when adapter is registered' do + before { config.register_delivery_adapter(:test_adapter, adapter_class: DummyDeliveryAdapter) } + + it 'does not raise error' do + expect { subject }.to_not raise_error + end - expect(config.delivery_adapter).to eq(adapter) + it 'updates the default adapter' do + expect { subject }.to change(config, :delivery_adapter).from(:sync).to(:test_adapter) end end - context 'when given unsupported option' do + context 'when adapter is not registered' do it 'raises unsupported option error' do - expect { config.delivery_adapter = :invalid }.to raise_error(EventRouter::Errors::UnsupportedOptionError) + expect { subject }.to raise_error(EventRouter::Errors::UnsupportedOptionError) end end end - describe '#delivery_strategy=' do - EventRouter::Configuration::DELIVERY_STRATEGIES.each do |strategy| - it "supports #{strategy} strategy" do - expect { config.delivery_strategy = strategy }.to_not raise_error - end + describe '#register_delivery_adapter' do + subject { config.register_delivery_adapter(:test_adapter, adapter_class: DummyDeliveryAdapter, key: :value) } + + before { subject } + + it 'updates delivery adapters' do + expect(config.delivery_adapters[:test_adapter]).to eq(DummyDeliveryAdapter) + end - it "updates the configuration to #{strategy}" do - config.delivery_strategy = strategy + it 'sets the adapter options' do + expect(DummyDeliveryAdapter.options).to include(key: :value) + end + end + + describe '#delivery_adapter_class' do + subject { config.delivery_adapter_class(:test_adapter) } + + before { config.register_delivery_adapter(:test_adapter, adapter_class: DummyDeliveryAdapter) } + + it 'returns the adapter class' do + expect(subject).to eq(DummyDeliveryAdapter) + end + end - expect(config.delivery_strategy).to eq(strategy) + describe '#serializer_adapter=' do + subject { config.serializer_adapter = :test_adapter } + + context 'when adapter is registered' do + before { config.register_serializer_adapter(:test_adapter, adapter_class: DummySerializerAdapter) } + + it 'does not raise error' do + expect { subject }.to_not raise_error + end + + it 'updates the default adapter' do + expect { subject }.to change(config, :serializer_adapter).from(:json).to(:test_adapter) end end - context 'when given unsupported option' do + context 'when adapter is not registered' do it 'raises unsupported option error' do - expect { config.delivery_strategy = :invalid }.to raise_error(EventRouter::Errors::UnsupportedOptionError) + expect { subject }.to raise_error(EventRouter::Errors::UnsupportedOptionError) end end end + + describe '#register_serializer_adapter' do + subject { config.register_serializer_adapter(:test_adapter, adapter_class: DummySerializerAdapter, key: :value) } + + before { subject } + + it 'updates serializer adapters' do + expect(config.serializer_adapters[:test_adapter]).to eq(DummySerializerAdapter) + end + end + + describe '#serializer_adapter_class' do + subject { config.serializer_adapter_class(:test_adapter) } + + before { config.register_serializer_adapter(:test_adapter, adapter_class: DummySerializerAdapter) } + + it 'returns the adapter class' do + expect(subject).to eq(DummySerializerAdapter) + end + end end diff --git a/spec/event_router/delivery_adapters/sync_spec.rb b/spec/event_router/delivery_adapters/sync_spec.rb new file mode 100644 index 0000000..7b38dd9 --- /dev/null +++ b/spec/event_router/delivery_adapters/sync_spec.rb @@ -0,0 +1,26 @@ +require 'event_router/delivery_adapters/sync' + +RSpec.describe EventRouter::DeliveryAdapters::Sync do + describe '.deliver' do + subject { described_class.deliver(event) } + + let(:event) { DummyEvent.new } + + it 'fetches the extra payload once per destination' do + event.destinations.each do |_name, destination| + expect(destination).to receive(:extra_payload).once.with(event) + end + + subject + end + + it 'delivers the event once per destination' do + event.destinations.each do |_name, destination| + expect(destination).to receive(:process) + .once.with(event, destination.extra_payload(event)) + end + + subject + end + end +end diff --git a/spec/event_router/destination_spec.rb b/spec/event_router/destination_spec.rb new file mode 100644 index 0000000..e06c2f2 --- /dev/null +++ b/spec/event_router/destination_spec.rb @@ -0,0 +1,84 @@ +RSpec.describe EventRouter::Destination do + describe '#process' do + subject { destination.process(event, payload) } + + let(:event) { DummyEvent.new } + let(:payload) { nil } + + context 'when handler_method is not defined' do + let(:destination) { described_class.new(:test, handler: DummyHandler) } + + it 'calls a handler with the same event name' do + expect(DummyHandler).to receive(:dummy_event).once.with(event: event, payload: payload) + + subject + end + end + + context 'with custom handler method' do + let(:destination) { described_class.new(:test, handler: DummyHandler, handler_method: :custom_handler) } + + it 'calls a custom handler method' do + expect(DummyHandler).to receive(:custom_handler).once.with(event: event, payload: payload) + + subject + end + end + end + + describe '#prefetch_payload?' do + subject { destination.prefetch_payload? } + + let(:destination) { described_class.new(:test, handler: DummyHandler, prefetch_payload: enabled) } + + context 'when prefetch enabled' do + let(:enabled) { true } + + it { is_expected.to be_truthy } + end + + context 'when prefetch disabled' do + let(:enabled) { false } + + it { is_expected.to be_falsey } + end + end + + describe '#extra_payload' do + subject { destination.extra_payload(event) } + + let(:event) { DummyEvent.new } + + context 'when custom payload method is not set' do + let(:destination) { described_class.new(:test, handler: DummyHandler) } + + context 'and there is payload method with destination name' do + let(:payload) { { id: 1 } } + + before { allow(event).to receive(:test_payload) { payload } } + + it { is_expected.to eq(payload) } + end + + context 'and there is not payload method with destination name' do + it { is_expected.to eq(nil) } + end + end + + context 'when custom payload method is set' do + let(:destination) { described_class.new(:test, handler: DummyHandler, payload_method: :custom_payload_method) } + + context 'and event implements the payload method' do + let(:payload) { { id: 1 } } + + before { allow(event).to receive(:custom_payload_method) { payload } } + + it { is_expected.to eq(payload) } + end + + context 'and event does not implement the payload method' do + it { is_expected.to eq(nil) } + end + end + end +end diff --git a/spec/event_router/event_spec.rb b/spec/event_router/event_spec.rb new file mode 100644 index 0000000..a4856ef --- /dev/null +++ b/spec/event_router/event_spec.rb @@ -0,0 +1,39 @@ +RSpec.describe EventRouter::Event do + let(:event) { DummyEvent.new } + + describe '#name' do + subject { event.name } + + it { is_expected.to eq('dummy_event') } + end + + describe '#to_hash' do + subject { event.to_hash } + + it { is_expected.to include(:uid, :correlation_id, :payload, :created_at) } + end + + describe '.deliver_to' do + subject { DummyEvent.deliver_to(:test_destination, handler: DummyHandler) } + + it 'creates a new destination on the class level' do + expect { subject }.to change { DummyEvent.destinations.count }.by(1) + end + + it 'adds the new destination' do + subject + + expect(DummyEvent.destinations).to have_key(:test_destination) + end + end + + describe '.publish' do + subject { DummyEvent.publish } + + it 'delegates to EventRouter.publish' do + expect(EventRouter).to receive(:publish).once.with(instance_of(DummyEvent)) + + subject + end + end +end diff --git a/spec/event_router/publisher_spec.rb b/spec/event_router/publisher_spec.rb new file mode 100644 index 0000000..7c59a1b --- /dev/null +++ b/spec/event_router/publisher_spec.rb @@ -0,0 +1,36 @@ +RSpec.describe EventRouter::Publisher do + describe 'ADAPTERS' do + it { expect(described_class::ADAPTERS).to include(:sync, :sidekiq) } + + it 'defines sync attributes' do + expect(described_class::ADAPTERS[:sync]).to eq( + adapter_class: 'EventRouter::DeliveryAdapters::Sync', + path: 'delivery_adapters/sync' + ) + end + + it 'defines sidekiq attributes' do + expect(described_class::ADAPTERS[:sidekiq]).to eq( + adapter_class: 'EventRouter::DeliveryAdapters::Sidekiq', + path: 'delivery_adapters/sidekiq' + ) + end + end + + describe '.publish' do + subject { described_class.publish([event, event], adapter: :test_adapter) } + + let(:event) { DummyEvent.new } + + before do + allow(EventRouter.configuration).to receive(:delivery_adapter_class) { DummyDeliveryAdapter } + end + + it 'delivers the event to the adapter' do + expect(EventRouter.configuration).to receive(:delivery_adapter_class).once.with(:test_adapter) + expect(DummyDeliveryAdapter).to receive(:deliver).twice.with(event) + + subject + end + end +end diff --git a/spec/event_router/serializer_spec.rb b/spec/event_router/serializer_spec.rb new file mode 100644 index 0000000..bac4e94 --- /dev/null +++ b/spec/event_router/serializer_spec.rb @@ -0,0 +1,53 @@ +RSpec.describe EventRouter::Serializer do + describe 'ADAPTERS' do + it { expect(described_class::ADAPTERS).to include(:json, :oj) } + + it 'defines json attributes' do + expect(described_class::ADAPTERS[:json]).to eq( + adapter_class: 'EventRouter::Serializers::Json', + path: 'serializers/json' + ) + end + + it 'defines oj attributes' do + expect(described_class::ADAPTERS[:oj]).to eq( + adapter_class: 'EventRouter::Serializers::Oj', + path: 'serializers/oj' + ) + end + end + + describe '.serialize' do + subject { described_class.serialize(event, adapter: :test_adapter) } + + let(:event) { DummyEvent.new } + + before do + allow(EventRouter.configuration).to receive(:serializer_adapter_class) { DummySerializerAdapter } + end + + it 'delegates the event to the adapter' do + expect(EventRouter.configuration).to receive(:serializer_adapter_class).once.with(:test_adapter) + expect(DummySerializerAdapter).to receive(:serialize).once.with(event) + + subject + end + end + + describe '.deserialize' do + subject { described_class.deserialize(payload, adapter: :test_adapter) } + + let(:payload) { { a: 1 }.to_json } + + before do + allow(EventRouter.configuration).to receive(:serializer_adapter_class) { DummySerializerAdapter } + end + + it 'delegates the event with the adapter' do + expect(EventRouter.configuration).to receive(:serializer_adapter_class).once.with(:test_adapter) + expect(DummySerializerAdapter).to receive(:deserialize).once.with(payload) + + subject + end + end +end diff --git a/spec/event_router/serializers/json_spec.rb b/spec/event_router/serializers/json_spec.rb new file mode 100644 index 0000000..e6fd448 --- /dev/null +++ b/spec/event_router/serializers/json_spec.rb @@ -0,0 +1,20 @@ +require 'event_router/serializers/json' + +RSpec.describe EventRouter::Serializers::Json do + let(:input) { { sym_key: 1, 'str_key' => 'b', 'bool' => true, c: { a: 1 } } } + let(:output) { '{"sym_key":1,"str_key":"b","bool":true,"c":{"a":1}}' } + + describe '.serialize' do + subject { described_class.serialize(input) } + + it { is_expected.to eq(output) } + end + + describe '.deserialize' do + subject { described_class.deserialize(output) } + + let(:input) { { 'sym_key' => 1, 'str_key' => 'b', 'bool' => true, 'c' => { 'a' => 1 } } } + + it { is_expected.to eq(input) } + end +end diff --git a/spec/event_router/serializers/oj_spec.rb b/spec/event_router/serializers/oj_spec.rb new file mode 100644 index 0000000..7f1eb53 --- /dev/null +++ b/spec/event_router/serializers/oj_spec.rb @@ -0,0 +1,18 @@ +require 'event_router/serializers/oj' + +RSpec.describe EventRouter::Serializers::Oj do + let(:input) { { sym_key: 1, 'str_key' => 'b', 'bool' => true, c: { a: 1 } } } + let(:output) { '{":sym_key":1,"str_key":"b","bool":true,":c":{":a":1}}' } + + describe '.serialize' do + subject { described_class.serialize(input) } + + it { is_expected.to eq(output) } + end + + describe '.deserialize' do + subject { described_class.deserialize(output) } + + it { is_expected.to eq(input) } + end +end diff --git a/spec/event_router_spec.rb b/spec/event_router_spec.rb index 34237da..2415bc6 100644 --- a/spec/event_router_spec.rb +++ b/spec/event_router_spec.rb @@ -3,6 +3,39 @@ expect(EventRouter::VERSION).not_to be nil end + describe '.publish' do + subject { EventRouter.publish(event) } + + let(:event) { DummyEvent.new } + + it 'delegates to publisher' do + expect(EventRouter::Publisher).to receive(:publish).once.with(event, adapter: :sync) + subject + end + end + + describe '.serialize' do + subject { EventRouter.serialize(event) } + + let(:event) { DummyEvent.new } + + it 'delegates to serializer' do + expect(EventRouter::Serializer).to receive(:serialize).once.with(event, adapter: :json) + subject + end + end + + describe '.deserialize' do + subject { EventRouter.deserialize(payload) } + + let(:payload) { { a: 1 }.to_json } + + it 'delegates to serializer' do + expect(EventRouter::Serializer).to receive(:deserialize).once.with(payload, adapter: :json) + subject + end + end + describe '.configuration' do it { expect(EventRouter.configuration).to be_instance_of(EventRouter::Configuration) } end @@ -11,11 +44,5 @@ it 'yields the configuration' do expect { |b| EventRouter.configure(&b) }.to yield_with_args(EventRouter.configuration) end - - it 'memoizes the configurations' do - expect do - EventRouter.configure { |c| c.delivery_strategy = :sync } - end.to change { EventRouter.configuration.delivery_strategy }.to(:sync) - end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 95935db..0f779ff 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,11 @@ require 'bundler/setup' require 'event_router' +require 'test_adapters' +require 'test_events' RSpec.configure do |config| + config.formatter = :documentation + # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' diff --git a/spec/test_adapters.rb b/spec/test_adapters.rb new file mode 100644 index 0000000..31592b6 --- /dev/null +++ b/spec/test_adapters.rb @@ -0,0 +1,2 @@ +class DummyDeliveryAdapter < EventRouter::DeliveryAdapters::Base; end +class DummySerializerAdapter < EventRouter::Serializers::Base; end diff --git a/spec/test_events.rb b/spec/test_events.rb new file mode 100644 index 0000000..6c76f43 --- /dev/null +++ b/spec/test_events.rb @@ -0,0 +1,18 @@ +class DummyHandler + def self.dummy_event(event:, payload:); end + + def self.custom_handler(event:, payload:); end +end + +class DummyEvent < EventRouter::Event + deliver_to :default_handler, handler: DummyHandler + deliver_to :custom_handler, handler: DummyHandler, handler_method: :custom_handler + + def default_handler_payload + { id: 't1' } + end + + def custom_handler_payload + { id: 't2' } + end +end