diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab53a8..d4e2eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +## [0.5.1] - 2026-05-25 + +### Added + +- **Conditional emission on `emits`**: The `emits` macro now accepts `if:` and `unless:` options + to gate whether an event fires at runtime. When the condition is not met, the event is completely + skipped — no payload is built, no validation runs, and nothing reaches the bus. Both options accept + a lambda/proc (receives the `result` object) or a Symbol naming a private instance method. + Fully backwards compatible — existing `emits` declarations without conditions are unaffected. + + ```ruby + emits :large_transfer_event, on: :success, if: ->(result) { result.data[:transferred] > 100 } + emits :standard_transfer_event, on: :success, unless: ->(result) { result.data[:transferred] > 100 } + emits :vip_transfer_event, on: :success, if: :vip_sender? + ``` + +- **`failure_message_when_negated` on `emit_event` matcher**: `expect { }.not_to emit_event(:name)` + now produces a clear failure message when the event was unexpectedly emitted. + +- **`with:` option moved to `**options`**: The `emits` macro signature is now + `emits(event_name, on:, **options, &block)` — `with:`, `if:`, and `unless:` are all uniform + keyword options. No change to calling code; `emits :name, on: :success, with: :method` still works. + ## [0.5.0] - 2026-05-07 ### Breaking Changes diff --git a/gem/Gemfile b/gem/Gemfile index a474349..b23a77e 100644 --- a/gem/Gemfile +++ b/gem/Gemfile @@ -5,7 +5,6 @@ source 'https://rubygems.org' # Specify your gem's dependencies in servus.gemspec gemspec -gem 'active_model_serializers' gem 'activesupport' gem 'json-schema' gem 'rake', '~> 13.0' diff --git a/gem/Gemfile.lock b/gem/Gemfile.lock index d0307b0..2f77f16 100644 --- a/gem/Gemfile.lock +++ b/gem/Gemfile.lock @@ -1,8 +1,7 @@ PATH remote: . specs: - servus (0.5.0) - active_model_serializers (~> 0.10.0) + servus (0.5.1) activesupport (>= 8.0) json-schema (~> 5) @@ -25,11 +24,6 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - active_model_serializers (0.10.15) - actionpack (>= 4.1) - activemodel (>= 4.1) - case_transform (>= 0.2) - jsonapi-renderer (>= 0.1.1.beta1, < 0.3) activejob (8.1.3) activesupport (= 8.1.3) globalid (>= 0.3.6) @@ -58,8 +52,6 @@ GEM base64 (0.3.0) bigdecimal (3.3.1) builder (3.3.0) - case_transform (0.2) - activesupport concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) @@ -82,7 +74,6 @@ GEM json-schema (5.1.1) addressable (~> 2.8) bigdecimal (~> 3.1) - jsonapi-renderer (0.2.2) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -212,7 +203,6 @@ PLATFORMS DEPENDENCIES actionpack (>= 8.0) - active_model_serializers activejob activerecord activesupport diff --git a/gem/lib/servus.rb b/gem/lib/servus.rb index 90a40a2..a534cbb 100644 --- a/gem/lib/servus.rb +++ b/gem/lib/servus.rb @@ -5,8 +5,6 @@ require 'active_support' require 'active_support/core_ext/class/attribute' require 'active_support/core_ext/hash/indifferent_access' -require 'active_model_serializers' - # Servus namespace module Servus; end diff --git a/gem/lib/servus/events/emitter.rb b/gem/lib/servus/events/emitter.rb index adc3a85..37945b5 100644 --- a/gem/lib/servus/events/emitter.rb +++ b/gem/lib/servus/events/emitter.rb @@ -33,12 +33,15 @@ def self.emit_result_events!(instance, result) # Declares an event that this service will emit. # # Events are automatically emitted when the service completes with the specified - # trigger condition (:success, :failure, or :error). Use the `with` option to - # provide a custom payload builder, or pass a block. + # trigger condition (:success, :failure, or :error). Use the `with` option or a + # block to provide a custom payload builder. Use `if` or `unless` to gate emission + # on a runtime condition. # # @param event_name [Symbol] the name of the event to emit - # @param on [Symbol] when to emit (:success, :failure, or :error) - # @param with [Symbol, nil] optional instance method name for building the payload + # @param on [Symbol] when to emit (:success, :failure, or :error!) + # @option options [Symbol, nil] :with instance method name for building the payload + # @option options [Proc, Symbol, nil] :if condition proc or method name; event only emits when truthy + # @option options [Proc, Symbol, nil] :unless condition proc or method name; event only emits when falsy # @yield [result] optional block for building the payload # @yieldparam result [Servus::Support::Response] the service result # @yieldreturn [Hash] the event payload @@ -67,6 +70,22 @@ def self.emit_result_events!(instance, result) # end # end # + # @example Conditional emission with if: lambda + # class CreateUser < Servus::Base + # emits :premium_user_created, on: :success, if: ->(result) { result.data[:plan] == :premium } + # end + # + # @example Conditional emission with unless: method reference + # class CreateUser < Servus::Base + # emits :user_created, on: :success, unless: :suppressed? + # + # private + # + # def suppressed?(result) + # result.data[:suppressed] + # end + # end + # # @note Best Practice: Services should typically emit ONE event per trigger # that represents their core concern. Multiple downstream reactions should # be coordinated by Event classes, not by emitting multiple events @@ -87,7 +106,7 @@ def self.emit_result_events!(instance, result) # # @see Servus::Events::Bus # @see Servus::Event - def emits(event_name, on:, with: nil, &block) + def emits(event_name, on:, **options, &block) valid_triggers = %i[success failure error!] unless valid_triggers.include?(on) @@ -95,10 +114,7 @@ def emits(event_name, on:, with: nil, &block) end @event_emissions ||= { success: [], failure: [], error!: [] } - @event_emissions[on] << { - event_name: event_name, - payload_builder: block || with - } + @event_emissions[on] << build_emission(event_name, options, block) end # Returns all event emissions declared for this service. @@ -116,6 +132,17 @@ def event_emissions def emissions_for(trigger) event_emissions[trigger] || [] end + + private + + def build_emission(event_name, options, block) + { + event_name: event_name, + if_condition: options[:if], + unless_condition: options[:unless], + payload_builder: block || options[:with] + } + end end # Emits events for a specific trigger with the given result. @@ -126,6 +153,8 @@ def emissions_for(trigger) # @api private def emit_events_for(trigger, result) self.class.emissions_for(trigger).each do |emission| + next unless emission_condition_met?(emission, result) + payload = build_event_payload(emission, result) validate_event_payload!(emission[:event_name], payload) Servus::Events::Bus.emit(emission[:event_name], payload) @@ -133,7 +162,35 @@ def emit_events_for(trigger, result) end # Instance methods for emitting events during service execution - private + + # Returns true when all declared conditions on the emission pass. + # + # @param emission [Hash] the emission configuration + # @param result [Servus::Support::Response] the service result + # @return [Boolean] + # @api private + def emission_condition_met?(emission, result) + if_condition = emission[:if_condition] + unless_condition = emission[:unless_condition] + + return false if if_condition && !evaluate_emission_condition(if_condition, result) + return false if unless_condition && evaluate_emission_condition(unless_condition, result) + + true + end + + # Evaluates a single emission condition — either a Proc/lambda or a Symbol method reference. + # + # Both forms receive the result object so conditions can inspect result.data, + # result.error, result.success?, etc. + # + # @param condition [Proc, Symbol] the condition to evaluate + # @param result [Servus::Support::Response] the service result + # @return [Object] truthy or falsy value + # @api private + def evaluate_emission_condition(condition, result) + condition.is_a?(Proc) ? condition.call(result) : send(condition, result) + end # Validates the payload against the Event class's schema registered for the event. # diff --git a/gem/lib/servus/testing/matchers.rb b/gem/lib/servus/testing/matchers.rb index e1d66e4..7f55db1 100644 --- a/gem/lib/servus/testing/matchers.rb +++ b/gem/lib/servus/testing/matchers.rb @@ -55,6 +55,11 @@ module Matchers "got: #{@matching_event[:payload].inspect}" end end + + failure_message_when_negated do + "expected event :#{@event_name} not to be emitted, but it was.\n" \ + "Payload: #{@matching_event[:payload].inspect}" + end end # Matcher for asserting service invocation diff --git a/gem/lib/servus/version.rb b/gem/lib/servus/version.rb index ce284df..2851845 100644 --- a/gem/lib/servus/version.rb +++ b/gem/lib/servus/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Servus - VERSION = '0.5.0' + VERSION = '0.5.1' end diff --git a/gem/servus.gemspec b/gem/servus.gemspec index 6d0a6ce..1efb0bd 100644 --- a/gem/servus.gemspec +++ b/gem/servus.gemspec @@ -27,7 +27,6 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] # Uncomment to register a new dependency of your gem - spec.add_dependency 'active_model_serializers', '~> 0.10.0' spec.add_dependency 'activesupport', '>= 8.0' spec.add_dependency 'json-schema', '~> 5' diff --git a/gem/spec/servus/base_events_spec.rb b/gem/spec/servus/base_events_spec.rb index abb5418..caf3953 100644 --- a/gem/spec/servus/base_events_spec.rb +++ b/gem/spec/servus/base_events_spec.rb @@ -213,6 +213,203 @@ def call end end + describe 'conditional event emission' do + describe 'if: condition' do + it 'emits when the lambda returns truthy' do + service_class = stub_const('IfLambdaTruthyService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, if: ->(result) { result.data[:amount] > 100 } + + def call + success({ amount: 150 }) + end + end) + + expect { service_class.call }.to emit_event(:conditional_event) + end + + it 'does not emit when the lambda returns falsy' do + service_class = stub_const('IfLambdaFalsyService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, if: ->(result) { result.data[:amount] > 100 } + + def call + success({ amount: 50 }) + end + end) + + expect { service_class.call }.not_to emit_event(:conditional_event) + end + + it 'emits when the method reference returns truthy' do + service_class = stub_const('IfMethodTruthyService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, if: :large_amount? + + def call + success({ amount: 150 }) + end + + private + + def large_amount?(result) + result.data[:amount] > 100 + end + end) + + expect { service_class.call }.to emit_event(:conditional_event) + end + + it 'does not emit when the method reference returns falsy' do + service_class = stub_const('IfMethodFalsyService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, if: :large_amount? + + def call + success({ amount: 50 }) + end + + private + + def large_amount?(result) + result.data[:amount] > 100 + end + end) + + expect { service_class.call }.not_to emit_event(:conditional_event) + end + end + + describe 'unless: condition' do + it 'does not emit when the lambda returns truthy' do + service_class = stub_const('UnlessLambdaTruthyService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, unless: ->(result) { result.data[:internal] } + + def call + success({ internal: true }) + end + end) + + expect { service_class.call }.not_to emit_event(:conditional_event) + end + + it 'emits when the lambda returns falsy' do + service_class = stub_const('UnlessLambdaFalsyService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, unless: ->(result) { result.data[:internal] } + + def call + success({ internal: false }) + end + end) + + expect { service_class.call }.to emit_event(:conditional_event) + end + + it 'does not emit when the method reference returns truthy' do + service_class = stub_const('UnlessMethodTruthyService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, unless: :internal_transfer? + + def call + success({ internal: true }) + end + + private + + def internal_transfer?(result) + result.data[:internal] + end + end) + + expect { service_class.call }.not_to emit_event(:conditional_event) + end + + it 'emits when the method reference returns falsy' do + service_class = stub_const('UnlessMethodFalsyService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, unless: :internal_transfer? + + def call + success({ internal: false }) + end + + private + + def internal_transfer?(result) + result.data[:internal] + end + end) + + expect { service_class.call }.to emit_event(:conditional_event) + end + end + + describe 'combining if: and unless:' do + it 'emits when both conditions pass' do + service_class = stub_const('BothConditionsPassService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, + if: ->(result) { result.data[:amount] > 50 }, + unless: ->(result) { result.data[:internal] } + + def call + success({ amount: 150, internal: false }) + end + end) + + expect { service_class.call }.to emit_event(:conditional_event) + end + + it 'does not emit when if: passes but unless: blocks' do + service_class = stub_const('UnlessBlocksService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, + if: ->(result) { result.data[:amount] > 50 }, + unless: ->(result) { result.data[:internal] } + + def call + success({ amount: 150, internal: true }) + end + end) + + expect { service_class.call }.not_to emit_event(:conditional_event) + end + + it 'does not emit when unless: passes but if: blocks' do + service_class = stub_const('IfBlocksService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, + if: ->(result) { result.data[:amount] > 50 }, + unless: ->(result) { result.data[:internal] } + + def call + success({ amount: 10, internal: false }) + end + end) + + expect { service_class.call }.not_to emit_event(:conditional_event) + end + end + + it 'backwards compatible: always emits when no condition is given' do + service_class = stub_const('UnconditionalEmitService', Class.new(Servus::Base) do + emits :unconditional_event, on: :success + + def call + success({ data: 'anything' }) + end + end) + + expect { service_class.call }.to emit_event(:unconditional_event) + end + + it 'still builds payload correctly when condition passes' do + service_class = stub_const('ConditionalPayloadService', Class.new(Servus::Base) do + emits :conditional_event, on: :success, + if: ->(result) { result.data[:amount] > 100 } + + def call + success({ amount: 150, label: 'large' }) + end + end) + + expect { service_class.call } + .to emit_event(:conditional_event) + .with(hash_including(amount: 150, label: 'large')) + end + end + describe 'automatic event emission' do it 'emits events on success' do service_class = stub_const('TestEventEmissionService', Class.new(Servus::Base) do diff --git a/site/features/event-bus.md b/site/features/event-bus.md index 363bb7f..bfefafe 100644 --- a/site/features/event-bus.md +++ b/site/features/event-bus.md @@ -63,6 +63,116 @@ def transfer_payload(result) end ``` +### Conditional emission + +Use `if:` or `unless:` to gate whether an event fires at runtime. When the condition is not met, the event is completely skipped — no payload is built, no validation runs, and nothing reaches the bus. + +Both options accept a **lambda/proc** or a **method reference** (Symbol). The condition always receives the `result` object, giving it access to `result.data`, `result.error`, `result.success?`, and `result.failure?`. + +::: info Conditions vs payload builders +The `&block` position on `emits` is already taken by the payload builder. Conditions must be passed as `if:` or `unless:` options — a proc/lambda or a Symbol naming a private instance method. +::: + +#### `if:` with a lambda + +The event fires only when the lambda returns a truthy value: + +```ruby +class Treasury::TransferGold::Service < Servus::Base + # Only notify the Iron Bank for large transfers + emits :large_transfer_event, on: :success, if: ->(result) { result.data[:transferred] > 100 } + + def call + from_account.withdraw!(@gold_dragons) + to_account.deposit!(@gold_dragons) + success(transferred: @gold_dragons, from_balance: from_account.balance, to_balance: to_account.balance) + end +end +``` + +#### `unless:` with a lambda + +The event fires only when the lambda returns a falsy value: + +```ruby +class Treasury::TransferGold::Service < Servus::Base + # Skip the standard receipt for large transfers (they get a different event) + emits :standard_transfer_event, on: :success, unless: ->(result) { result.data[:transferred] > 100 } + + def call + # ... + end +end +``` + +#### `if:` with a method reference + +Pass a Symbol to call a private instance method. The method receives the same `result` object: + +```ruby +class Treasury::TransferGold::Service < Servus::Base + emits :vip_transfer_event, on: :success, if: :vip_sender? + + def call + from_account.withdraw!(@gold_dragons) + to_account.deposit!(@gold_dragons) + success(transferred: @gold_dragons, account_tier: from_account.tier) + end + + private + + def vip_sender?(result) + result.data[:account_tier] == :vip + end +end +``` + +#### `unless:` with a method reference + +```ruby +class Treasury::TransferGold::Service < Servus::Base + emits :transfer_failed_event, on: :failure, unless: :suppressed_account? + + def call + # ... + end + + private + + def suppressed_account?(result) + result.error.message.include?("suppressed") + end +end +``` + +#### Combining `if:` and `unless:` + +Both conditions must pass for the event to emit. If either blocks, the event is skipped: + +```ruby +class Treasury::TransferGold::Service < Servus::Base + emits :audit_transfer_event, on: :success, + if: ->(result) { result.data[:transferred] > 50 }, + unless: :internal_transfer? + + def call + # ... + end + + private + + def internal_transfer?(result) + @to_account.internal? + end +end +``` + +::: tip Emission vs invocation conditions +`if:`/`unless:` on `emits` gate the **event itself** — when the condition fails, the event never enters the bus and no handlers run. + +The `if:`/`unless:` on `invoke` (inside an Event class) gate a **specific handler** — the event fires and reaches the bus, but only matching handlers are invoked. Use emission conditions when the entire event is irrelevant; use invocation conditions when only some handlers should react. +::: + ## Handling events A service can emit events without knowing or caring whether anything is listening. The service's job ends when the event fires — it has no dependency on what happens next. diff --git a/site/testing/services.md b/site/testing/services.md index 459c04b..541b954 100644 --- a/site/testing/services.md +++ b/site/testing/services.md @@ -363,3 +363,33 @@ expect { }.to emit_event(GoldTransferredEvent) ``` +### Asserting non-emission + +Use `not_to emit_event` to assert that a service does **not** emit an event. This is particularly useful when testing conditional emissions — verifying the event is suppressed when the condition is not met: + +```ruby +it "does not emit large_transfer_event for small transfers" do + expect { + described_class.call( + from_account: from_account, + to_account: to_account, + gold_dragons: 10 # below the 100-dragon threshold + ) + }.not_to emit_event(:large_transfer_event) +end +``` + +Combine with `emit_event` in the same example group to fully specify conditional behavior: + +```ruby +context "when transfer exceeds 100 gold dragons" do + it { expect { described_class.call(gold_dragons: 150, **accounts) }.to emit_event(:large_transfer_event) } + it { expect { described_class.call(gold_dragons: 150, **accounts) }.not_to emit_event(:standard_transfer_event) } +end + +context "when transfer is 100 gold dragons or fewer" do + it { expect { described_class.call(gold_dragons: 50, **accounts) }.not_to emit_event(:large_transfer_event) } + it { expect { described_class.call(gold_dragons: 50, **accounts) }.to emit_event(:standard_transfer_event) } +end +``` +