downstream 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 44d7614f5509bb04efddc18f5dd26f4030a4262c00e6a370da13797d1efd99a6
4
+ data.tar.gz: 8ea205b3d718e21b01a82cb65b51ae1870068f1065f679af4e4fe94bcdebe87a
5
+ SHA512:
6
+ metadata.gz: 5376229dc224f18496764928e4019bd72791ac660bc90f975abba38fe961e305dd9e6d997573aae7ef9acd3b785659ff9171678d2ccd1dd02f89add6fa5b01cb
7
+ data.tar.gz: f5a17a4a6f8383b35820914cc658fe97a41b2554df16c2088dcac3857af264e7976c5af3ab5249635e884de1ffe590dd32d270583230e79fa63ad09b5ae5294e
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Michael Merkushin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,122 @@
1
+ [![Gem Version](https://badge.fury.io/rb/downstream.svg)](https://badge.fury.io/rb/downstream)
2
+ [![Build Status](https://github.com/bibendi/downstream/workflows/Ruby/badge.svg?branch=master)](https://github.com/bibendi/downstream/actions?query=branch%3Amaster)
3
+
4
+ # Downstream
5
+
6
+ This gem provides a straightforward way to implement communication between Rails Engines using the Publish-Subscribe pattern. The gem allows decreasing decoupling engines with events. An event is a recorded object in the system that reflects an action that the engine performs, and the params that lead to its creation.
7
+
8
+ The gem inspired by [`active_event_store`](https://github.com/palkan/active_event_store), and initially based on its codebase. Having said that, it does not store in a database all happened events which ensures simplicity and performance.
9
+
10
+ <a href="https://evilmartians.com/?utm_source=bibendi-downstream">
11
+ <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem "downstream", "~> 1.0"
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Under the hood it's a wrapper for `ActiveSupport::Notifications`. But it provides a way more handy interface to build reactive apps. Each event has a strict schema described by a separate class. Also, the gem has convenient tooling to write tests.
24
+
25
+ ### Describe events
26
+
27
+ Events are represented by _event classes_, which describe events payloads and identifiers:
28
+
29
+ ```ruby
30
+ class ProfileCreated < Downstream::Event
31
+ # (optional)
32
+ # Event identifier is used for streaming events to subscribers.
33
+ # By default, identifier is equal to underscored class name.
34
+ # You don't need to specify identifier manually, only for backward compatibility when
35
+ # class name is changed.
36
+ self.identifier = "profile_created"
37
+
38
+ # Add attributes accessors
39
+ attributes :user
40
+ end
41
+ ```
42
+
43
+ Each event has predefined (_reserved_) fields:
44
+ - `event_id` – unique event id
45
+ - `type` – event type (=identifier)
46
+
47
+ **NOTE:** events should be in the past tense and describe what happened (e.g. "ProfileCreated", "EventPublished", etc.).
48
+
49
+ Events are stored in `app/events` folder.
50
+
51
+ ### Publish events
52
+
53
+ To publish an event you must first create an instance of the event class and call `Downstream.publish` method:
54
+
55
+ ```ruby
56
+ event = ProfileCompleted.new(user: user)
57
+
58
+ # then publish the event
59
+ Downstream.publish(event)
60
+ ```
61
+
62
+ That's it! Your event has been stored and propagated.
63
+
64
+ ### Subscribe to events
65
+
66
+ To subscribe a handler to an event you must use `Downstream.subscribe` method.
67
+
68
+ You should do this in your app or engine initializer:
69
+
70
+ ```ruby
71
+ # some/engine.rb
72
+
73
+ initializer "my_engine.subscribe_to_events" do
74
+ # To make sure event store is initialized use load hook
75
+ # `store` == `Downstream`
76
+ ActiveSupport.on_load "downstream-events" do |store|
77
+ store.subscribe MyEventHandler, to: ProfileCreated
78
+
79
+ # anonymous handler (could only be synchronous)
80
+ store.subscribe(to: ProfileCreated) do |name, event|
81
+ # do something
82
+ end
83
+
84
+ # you can omit event if your subscriber follows the convention
85
+ # for example, the following subscriber would subscribe to
86
+ # ProfileCreated event
87
+ store.subscribe OnProfileCreated::DoThat
88
+ end
89
+ end
90
+ ```
91
+
92
+ **NOTE:** event handler **must** be a callable object.
93
+
94
+ Although subscriber could be any callable Ruby object, that have specific input format (event); thus we suggest putting subscribers under `app/subscribers/on_<event_type>/<subscriber.rb>`, e.g. `app/subscribers/on_profile_created/create_chat_user.rb`).
95
+
96
+ ## Testing
97
+
98
+ You can test subscribers as normal Ruby objects.
99
+
100
+ To test that a given subscriber exists, you can do the following:
101
+
102
+ ```ruby
103
+ it "is subscribed to some event" do
104
+ allow(MySubscriberService).to receive(:call)
105
+
106
+ event = MyEvent.new(some: "data")
107
+
108
+ Downstream.publish event
109
+
110
+ expect(MySubscriberService).to have_received(:call).with(event)
111
+ end
112
+ ```
113
+
114
+ To test publishing use `have_published_event` matcher:
115
+
116
+ ```ruby
117
+ expect { subject }.to have_published_event(ProfileCreated).with(user: user)
118
+ ```
119
+
120
+ **NOTE:** `have_published_event` only supports block expectations.
121
+
122
+ **NOTE 2** `with` modifier works like `have_attributes` matcher (not `contain_exactly`);
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Downstream
4
+ class Config
5
+ attr_writer :namespace
6
+
7
+ def namespace
8
+ @namespace ||= "downstream-events"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Downstream
6
+ class Engine < ::Rails::Engine
7
+ config.downstream = Downstream.config
8
+
9
+ config.to_prepare do
10
+ ActiveSupport.run_load_hooks("downstream-events", Downstream)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Downstream
4
+ class Event
5
+ extend ActiveModel::Naming
6
+
7
+ RESERVED_ATTRIBUTES = %i[event_id type].freeze
8
+
9
+ class << self
10
+ attr_writer :identifier
11
+
12
+ def identifier
13
+ return @identifier if instance_variable_defined?(:@identifier)
14
+
15
+ @identifier = name.underscore.tr("/", ".")
16
+ end
17
+
18
+ # define store readers
19
+ def attributes(*fields)
20
+ fields.each do |field|
21
+ raise ArgumentError, "#{field} is reserved" if RESERVED_ATTRIBUTES.include?(field)
22
+
23
+ defined_attributes << field
24
+
25
+ # TODO: rewrite with define_method
26
+ class_eval <<~CODE, __FILE__, __LINE__ + 1
27
+ def #{field}
28
+ data[:#{field}]
29
+ end
30
+ CODE
31
+ end
32
+ end
33
+
34
+ def defined_attributes
35
+ return @defined_attributes if instance_variable_defined?(:@defined_attributes)
36
+
37
+ @defined_attributes =
38
+ if superclass.respond_to?(:defined_attributes)
39
+ superclass.defined_attributes.dup
40
+ else
41
+ []
42
+ end
43
+ end
44
+
45
+ def i18n_scope
46
+ :activemodel
47
+ end
48
+
49
+ def human_attribute_name(attr, options = {})
50
+ attr
51
+ end
52
+
53
+ def lookup_ancestors
54
+ [self]
55
+ end
56
+ end
57
+
58
+ attr_reader :event_id, :data, :errors
59
+
60
+ def initialize(event_id: nil, **params)
61
+ @event_id = event_id || SecureRandom.hex(10)
62
+ validate_attributes!(params)
63
+
64
+ @errors = ActiveModel::Errors.new(self)
65
+ @data = params
66
+ end
67
+
68
+ def type
69
+ self.class.identifier
70
+ end
71
+
72
+ def to_h
73
+ {
74
+ type: type,
75
+ event_id: event_id,
76
+ data: data
77
+ }
78
+ end
79
+
80
+ def inspect
81
+ "#{self.class.name}<#{type}##{event_id}>, data: #{data}"
82
+ end
83
+
84
+ def read_attribute_for_validation(attr)
85
+ data.fetch(attr)
86
+ end
87
+
88
+ private
89
+
90
+ def validate_attributes!(params)
91
+ unknown_fields = params.keys.map(&:to_sym) - self.class.defined_attributes
92
+ unless unknown_fields.empty?
93
+ raise ArgumentError, "Unknown event attributes: #{unknown_fields.join(", ")}"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Downstream
4
+ class HavePublishedEvent < RSpec::Matchers::BuiltIn::BaseMatcher
5
+ attr_reader :event_class, :attributes
6
+
7
+ def initialize(event_class)
8
+ @event_class = event_class
9
+ set_expected_number(:exactly, 1)
10
+ end
11
+
12
+ def with(attributes)
13
+ @attributes = attributes
14
+ self
15
+ end
16
+
17
+ def exactly(count)
18
+ set_expected_number(:exactly, count)
19
+ self
20
+ end
21
+
22
+ def at_least(count)
23
+ set_expected_number(:at_least, count)
24
+ self
25
+ end
26
+
27
+ def at_most(count)
28
+ set_expected_number(:at_most, count)
29
+ self
30
+ end
31
+
32
+ def times
33
+ self
34
+ end
35
+
36
+ def once
37
+ exactly(:once)
38
+ end
39
+
40
+ def twice
41
+ exactly(:twice)
42
+ end
43
+
44
+ def thrice
45
+ exactly(:thrice)
46
+ end
47
+
48
+ def supports_block_expectations?
49
+ true
50
+ end
51
+
52
+ def matches?(block)
53
+ raise ArgumentError, "have_published_event only supports block expectations" unless block.is_a?(Proc)
54
+
55
+ events = []
56
+ namespace = /^#{Downstream.config.namespace}\./
57
+ ActiveSupport::Notifications.subscribed(->(name, event) { events << event }, namespace) do
58
+ block.call
59
+ end
60
+
61
+ @matching_events, @unmatching_events =
62
+ events.partition do |actual_event|
63
+ (event_class.identifier == actual_event.type) &&
64
+ (attributes.nil? || attributes_match?(actual_event))
65
+ end
66
+
67
+ @matching_count = @matching_events.size
68
+
69
+ case @expectation_type
70
+ when :exactly then @expected_number == @matching_count
71
+ when :at_most then @expected_number >= @matching_count
72
+ when :at_least then @expected_number <= @matching_count
73
+ end
74
+ end
75
+
76
+ def failure_message
77
+ (+"expected to publish #{event_class.identifier} event").tap do |msg|
78
+ msg << " #{message_expectation_modifier}, but"
79
+
80
+ if @unmatching_events.any?
81
+ msg << " published the following events:"
82
+ @unmatching_events.each do |unmatching_event|
83
+ msg << "\n #{unmatching_event.inspect}"
84
+ end
85
+ else
86
+ msg << " haven't published anything"
87
+ end
88
+ end
89
+ end
90
+
91
+ def failure_message_when_negated
92
+ "expected not to publish #{event_class.identifier} event"
93
+ end
94
+
95
+ private
96
+
97
+ def attributes_match?(event)
98
+ RSpec::Matchers::BuiltIn::HaveAttributes.new(attributes).matches?(event)
99
+ end
100
+
101
+ def set_expected_number(relativity, count)
102
+ @expectation_type = relativity
103
+ @expected_number =
104
+ case count
105
+ when :once then 1
106
+ when :twice then 2
107
+ when :thrice then 3
108
+ else Integer(count)
109
+ end
110
+ end
111
+
112
+ def message_expectation_modifier
113
+ number_modifier = @expected_number == 1 ? "once" : "#{@expected_number} times"
114
+ case @expectation_type
115
+ when :exactly then "exactly #{number_modifier}"
116
+ when :at_most then "at most #{number_modifier}"
117
+ when :at_least then "at least #{number_modifier}"
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ RSpec.configure do |config|
124
+ config.include(Module.new do
125
+ def have_published_event(*args)
126
+ Downstream::HavePublishedEvent.new(*args)
127
+ end
128
+ end)
129
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rspec/have_published_event"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Downstream
4
+ VERSION = "1.0.0"
5
+ end
data/lib/downstream.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_model"
5
+
6
+ require "downstream/config"
7
+ require "downstream/event"
8
+ require "downstream/rspec" if defined?(RSpec)
9
+
10
+ module Downstream
11
+ class << self
12
+ def config
13
+ @config ||= Config.new
14
+ end
15
+
16
+ def subscribe(subscriber = nil, to: nil, &block)
17
+ to ||= infer_event_from_subscriber(subscriber) if subscriber.is_a?(Module)
18
+
19
+ if to.nil?
20
+ raise ArgumentError, "Couldn't infer event from subscriber. " \
21
+ "Please, specify event using `to:` option"
22
+ end
23
+
24
+ subscriber ||= block if block
25
+
26
+ if subscriber.nil?
27
+ raise ArgumentError, "Subsriber must be present"
28
+ end
29
+
30
+ identifier =
31
+ if to.is_a?(Class) && Event >= to
32
+ to.identifier
33
+ else
34
+ to
35
+ end
36
+
37
+ ActiveSupport::Notifications.subscribe("#{config.namespace}.#{identifier}", subscriber)
38
+ end
39
+
40
+ # temporary subscriptions
41
+ def subscribed(subscriber, to: nil, &block)
42
+ to ||= infer_event_from_subscriber(subscriber) if subscriber.is_a?(Module)
43
+
44
+ if to.nil?
45
+ raise ArgumentError, "Couldn't infer event from subscriber. " \
46
+ "Please, specify event using `to:` option"
47
+ end
48
+
49
+ identifier =
50
+ if to.is_a?(Class) && Event >= to
51
+ to.identifier
52
+ else
53
+ to
54
+ end
55
+
56
+ ActiveSupport::Notifications.subscribed(subscriber, "#{config.namespace}.#{identifier}", &block)
57
+ end
58
+
59
+ def publish(event)
60
+ ActiveSupport::Notifications.publish("#{config.namespace}.#{event.type}", event)
61
+ end
62
+
63
+ private
64
+
65
+ def infer_event_from_subscriber(subscriber)
66
+ event_class_name = subscriber.name.split("::").yield_self do |parts|
67
+ # handle explicti top-level name, e.g. ::Some::Event
68
+ parts.shift if parts.first.empty?
69
+ # drop last part – it's a unique subscriber name
70
+ parts.pop
71
+
72
+ parts.last.sub!(/^On/, "")
73
+
74
+ parts.join("::")
75
+ end
76
+
77
+ event_class_name.safe_constantize
78
+ end
79
+ end
80
+ end
81
+
82
+ require "downstream/engine"
metadata ADDED
@@ -0,0 +1,152 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: downstream
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - merkushin.m.s@gmail.com
8
+ - dementiev.vm@gmail.com
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2021-10-28 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rails
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '5'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '5'
28
+ - !ruby/object:Gem::Dependency
29
+ name: appraisal
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '2.2'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '2.2'
42
+ - !ruby/object:Gem::Dependency
43
+ name: bundler
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.16'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '1.16'
56
+ - !ruby/object:Gem::Dependency
57
+ name: debug
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '1.3'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '1.3'
70
+ - !ruby/object:Gem::Dependency
71
+ name: rake
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '13.0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: '13.0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rspec
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '3.0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '3.0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: standard
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - "~>"
103
+ - !ruby/object:Gem::Version
104
+ version: '1.3'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - "~>"
110
+ - !ruby/object:Gem::Version
111
+ version: '1.3'
112
+ description:
113
+ email:
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - LICENSE.txt
119
+ - README.md
120
+ - lib/downstream.rb
121
+ - lib/downstream/config.rb
122
+ - lib/downstream/engine.rb
123
+ - lib/downstream/event.rb
124
+ - lib/downstream/rspec.rb
125
+ - lib/downstream/rspec/have_published_event.rb
126
+ - lib/downstream/version.rb
127
+ homepage: https://github.com/bibendi/downstream
128
+ licenses:
129
+ - MIT
130
+ metadata:
131
+ allowed_push_host: https://rubygems.org
132
+ post_install_message:
133
+ rdoc_options: []
134
+ require_paths:
135
+ - lib
136
+ required_ruby_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '2.5'
141
+ required_rubygems_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ requirements: []
147
+ rubygems_version: 3.1.2
148
+ signing_key:
149
+ specification_version: 4
150
+ summary: Straightforward way to implement communication between Rails Engines using
151
+ the Publish-Subscribe pattern
152
+ test_files: []