downstream 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []