active_event_store 0.0.1 → 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 +4 -4
- data/CHANGELOG.md +17 -0
- data/LICENSE.txt +1 -1
- data/README.md +172 -16
- data/lib/active_event_store/config.rb +25 -0
- data/lib/active_event_store/engine.rb +36 -0
- data/lib/active_event_store/event.rb +110 -0
- data/lib/active_event_store/mapper.rb +54 -0
- data/lib/active_event_store/mapping.rb +27 -0
- data/lib/active_event_store/rspec/have_enqueued_async_subscriber_for.rb +73 -0
- data/lib/active_event_store/rspec/have_published_event.rb +135 -0
- data/lib/active_event_store/rspec.rb +3 -0
- data/lib/active_event_store/subscriber_job.rb +58 -0
- data/lib/active_event_store/version.rb +1 -1
- data/lib/active_event_store.rb +72 -1
- metadata +33 -11
- data/lib/active_event_store/railtie.rb +0 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8791927b35712c61094dcb6cc915e2b27912129e675c84230cd6f5271585902e
|
4
|
+
data.tar.gz: fe58f1151444dc521caa4666adf06dd97d00bc37882843b1252d776683a98dcf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 95f22443b58343d34b93b0400b53c63bf5190ff5f0c8e56c3c96c2e9ebf3d8137bee3136035f7f12d17f8c4488f8bdebef3a4e971eb67dfd17dbafdb48b857ba
|
7
|
+
data.tar.gz: c947302a3db94490b5a781f3fb74bacc0b45d437240599766dd33138147b8807119604105b55034482861254463f4a49919dba2689d52eb16599459c2fe15b7a
|
data/CHANGELOG.md
CHANGED
@@ -2,4 +2,21 @@
|
|
2
2
|
|
3
3
|
## master
|
4
4
|
|
5
|
+
## 1.0.0 (2021-01-14)
|
6
|
+
|
7
|
+
- Ruby 2.6+, Rails 6+ and RailsEventStore 2.1+ is required.
|
8
|
+
## 0.2.1 (2020-09-30)
|
9
|
+
|
10
|
+
- Fix Active Support load hook name. ([@palkan][])
|
11
|
+
|
12
|
+
Now `ActiveSupport.on_load(:active_event_store) { ... }` works.
|
13
|
+
|
14
|
+
## 0.2.0 (2020-05-11)
|
15
|
+
|
16
|
+
- Update Event API to support both RES 1.0 and 0.42+. ([@palkan][])
|
17
|
+
|
18
|
+
## 0.1.0 (2020-04-22)
|
19
|
+
|
20
|
+
- Open source Active Event Store. ([@palkan][])
|
21
|
+
|
5
22
|
[@palkan]: https://github.com/palkan
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
@@ -1,38 +1,194 @@
|
|
1
1
|
[](https://rubygems.org/gems/active_event_store) [](https://github.com/palkan/active_event_store/actions)
|
2
|
-
[](https://github.com/palkan/active_event_store/actions)
|
3
2
|
|
4
3
|
# Active Event Store
|
5
4
|
|
6
|
-
|
5
|
+
Active Event Store is a wrapper over [Rails Event Store](https://railseventstore.org/) which adds conventions and transparent Rails integration.
|
6
|
+
|
7
|
+
## Motivation
|
8
|
+
|
9
|
+
Why creating a wrapper and not using Rails Event Store itself?
|
10
|
+
|
11
|
+
RES is an awesome project but, in our opinion, it lacks Rails simplicity and elegance (=conventions and less boilerplate). It's an advanced tool for advanced developers. We've been using it in multiple projects in a similar way, and decided to extract our approach into this gem (originally private).
|
12
|
+
|
13
|
+
Secondly, we wanted to have a store implementation independent API that would allow us to adapterize the actual event store in the future (something like `ActiveEventStore.store_engine = :rails_event_store` or `ActiveEventStore.store_engine = :hanami_events`).
|
14
|
+
|
15
|
+
<a href="https://evilmartians.com/?utm_source=active_event_store">
|
16
|
+
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
|
7
17
|
|
8
18
|
## Installation
|
9
19
|
|
10
|
-
|
20
|
+
Add the gem to your project:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
# Gemfile
|
24
|
+
gem "active_event_store", "~> 1.0"
|
25
|
+
```
|
26
|
+
|
27
|
+
Setup database according to the [Rails Event Store docs](https://railseventstore.org/docs/install/#setup-data-model):
|
28
|
+
|
29
|
+
```sh
|
30
|
+
rails generate rails_event_store_active_record:migration
|
31
|
+
rails db:migrate
|
32
|
+
```
|
33
|
+
|
34
|
+
### Requirements
|
35
|
+
|
36
|
+
- Ruby (MRI) >= 2.6
|
37
|
+
- Rails >= 6.0
|
38
|
+
- RailsEventStore >= 2.1
|
39
|
+
|
40
|
+
## Usage
|
41
|
+
|
42
|
+
### Describe events
|
43
|
+
|
44
|
+
Events are represented by _event classes_, which describe events payloads and identifiers:
|
11
45
|
|
12
46
|
```ruby
|
13
|
-
|
14
|
-
|
15
|
-
#
|
16
|
-
|
17
|
-
#
|
47
|
+
class ProfileCompleted < ActiveEventStore::Event
|
48
|
+
# (optional) event identifier is used for transmitting events
|
49
|
+
# to subscribers.
|
50
|
+
#
|
51
|
+
# By default, identifier is equal to `name.underscore.gsub('/', '.')`.
|
52
|
+
#
|
53
|
+
# You don't need to specify identifier manually, only for backward compatibility when
|
54
|
+
# class name is changed.
|
55
|
+
self.identifier = "profile_completed"
|
56
|
+
|
57
|
+
# Add attributes accessors
|
58
|
+
attributes :user_id
|
59
|
+
|
60
|
+
# Sync attributes only available for sync subscribers
|
61
|
+
# (so you can add some optional non-JSON serializable data here)
|
62
|
+
# For example, we can also add `user` record to the event to avoid
|
63
|
+
# reloading in sync subscribers
|
64
|
+
sync_attributes :user
|
18
65
|
end
|
19
66
|
```
|
20
67
|
|
21
|
-
|
68
|
+
**NOTE:** we use JSON to [serialize events](https://railseventstore.org/docs/mapping_serialization/), thus only the simple field types (numbers, strings, booleans) are supported.
|
69
|
+
|
70
|
+
Each event has predefined (_reserved_) fields:
|
71
|
+
|
72
|
+
- `event_id` – unique event id
|
73
|
+
- `type` – event type (=identifier)
|
74
|
+
- `metadata`
|
75
|
+
|
76
|
+
We suggest to use a naming convention for event classes, for example, using the past tense and describe what happened (e.g. "ProfileCreated", "EventPublished", etc.).
|
77
|
+
|
78
|
+
We recommend to keep event definitions in the `app/events` folder.
|
79
|
+
|
80
|
+
### Events registration
|
81
|
+
|
82
|
+
Since we use _abstract_ identifiers instead of class names, we need a way to tell our _mapper_ how to infer an event class from its type.
|
83
|
+
|
84
|
+
In most cases, we register events automatically when they're published or when a subscription is created.
|
85
|
+
|
86
|
+
You can also register events manually:
|
22
87
|
|
23
88
|
```ruby
|
24
|
-
#
|
25
|
-
|
89
|
+
# by passing an event class
|
90
|
+
ActiveEventStore.mapper.register_event MyEventClass
|
91
|
+
|
92
|
+
# or more precisely (in that case `event.type` must be equal to "my_event")
|
93
|
+
ActiveEventStore.mapper.register "my_event", MyEventClass
|
26
94
|
```
|
27
95
|
|
28
|
-
###
|
96
|
+
### Publish events
|
29
97
|
|
30
|
-
|
31
|
-
- JRuby >= 9.2.9
|
98
|
+
To publish an event you must first create an instance of the event class and call `ActiveEventStore.publish` method:
|
32
99
|
|
33
|
-
|
100
|
+
```ruby
|
101
|
+
event = ProfileCompleted.new(user_id: user.id)
|
102
|
+
|
103
|
+
# or with metadata
|
104
|
+
event = ProfileCompleted.new(user_id: user.id, metadata: {ip: request.remote_ip})
|
105
|
+
|
106
|
+
# then publish the event
|
107
|
+
ActiveEventStore.publish(event)
|
108
|
+
```
|
109
|
+
|
110
|
+
That's it! Your event has been stored and propagated to the subscribers.
|
111
|
+
|
112
|
+
### Subscribe to events
|
113
|
+
|
114
|
+
To subscribe a handler to an event you must use `ActiveEventStore.subscribe` method.
|
115
|
+
|
116
|
+
You can do this in your app or engine initializer:
|
117
|
+
|
118
|
+
```ruby
|
119
|
+
# some/engine.rb
|
120
|
+
|
121
|
+
# To make sure event store has been initialized use the load hook
|
122
|
+
# `store` == `ActiveEventStore`
|
123
|
+
ActiveSupport.on_load :active_event_store do |store|
|
124
|
+
# async subscriber – invoked from background job, enqueued after the current transaction commits
|
125
|
+
# NOTE: all subscribers are asynchronous by default
|
126
|
+
store.subscribe MyEventHandler, to: ProfileCreated
|
127
|
+
|
128
|
+
# sync subscriber – invoked right "within" `publish` method
|
129
|
+
store.subscribe MyEventHandler, to: ProfileCreated, sync: true
|
130
|
+
|
131
|
+
# anonymous handler (could only be synchronous)
|
132
|
+
store.subscribe(to: ProfileCreated, sync: true) do |event|
|
133
|
+
# do something
|
134
|
+
end
|
135
|
+
|
136
|
+
# you can omit event if your subscriber follows the convention
|
137
|
+
# for example, the following subscriber would subscribe to
|
138
|
+
# ProfileCreated event
|
139
|
+
store.subscribe OnProfileCreated::DoThat
|
140
|
+
end
|
141
|
+
```
|
142
|
+
|
143
|
+
Subscribers could be any callable Ruby objects that accept a single argument (event) as its input.
|
144
|
+
|
145
|
+
We suggest putting subscribers to the `app/subscribers` folder using the following convention: `app/subscribers/on_<event_type>/<subscriber.rb>`, e.g. `app/subscribers/on_profile_created/create_chat_user.rb`.
|
146
|
+
|
147
|
+
**NOTE:** Active Job must be loaded to use async subscribers (i.e., `require "active_job/railtie"` or `require "rails/all"` in your `config/application.rb`).
|
148
|
+
|
149
|
+
### Testing
|
150
|
+
|
151
|
+
You can test subscribers as normal Ruby objects.
|
152
|
+
|
153
|
+
**NOTE:** Currently, we provide additional matchers only for RSpec. PRs with Minitest support are welcomed!
|
154
|
+
|
155
|
+
To test that a given subscriber exists, you can use the `have_enqueued_async_subscriber_for` matcher:
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
# for asynchronous subscriptions
|
159
|
+
it "is subscribed to some event" do
|
160
|
+
event = MyEvent.new(some: "data")
|
161
|
+
expect { ActiveEventStore.publish event }
|
162
|
+
.to have_enqueued_async_subscriber_for(MySubscriberService)
|
163
|
+
.with(event)
|
164
|
+
end
|
165
|
+
```
|
166
|
+
|
167
|
+
**NOTE:** You must have `rspec-rails` gem in your bundle to use `have_enqueued_async_subscriber_for` matcher.
|
168
|
+
|
169
|
+
For synchronous subscribers using `have_received` is enough:
|
170
|
+
|
171
|
+
```ruby
|
172
|
+
it "is subscribed to some event" do
|
173
|
+
allow(MySubscriberService).to receive(:call)
|
174
|
+
|
175
|
+
event = MyEvent.new(some: "data")
|
176
|
+
|
177
|
+
ActiveEventStore.publish event
|
178
|
+
|
179
|
+
expect(MySubscriberService).to have_received(:call).with(event)
|
180
|
+
end
|
181
|
+
```
|
182
|
+
|
183
|
+
To test event publishing, use `have_published_event` matcher:
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
expect { subject }.to have_published_event(ProfileCreated).with(user_id: user.id)
|
187
|
+
```
|
188
|
+
|
189
|
+
**NOTE:** `have_published_event` only supports block expectations.
|
34
190
|
|
35
|
-
|
191
|
+
**NOTE 2** `with` modifier works like `have_attributes` matcher (not `contain_exactly`); you can only specify serializable attributes in `with` (i.e. sync attributes are not supported, 'cause they are not persistent).
|
36
192
|
|
37
193
|
## Contributing
|
38
194
|
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module ActiveEventStore
|
6
|
+
class Config
|
7
|
+
attr_writer :repository, :serializer, :job_queue_name, :store_options
|
8
|
+
|
9
|
+
def repository
|
10
|
+
@repository ||= RailsEventStoreActiveRecord::EventRepository.new(serializer: serializer)
|
11
|
+
end
|
12
|
+
|
13
|
+
def serializer
|
14
|
+
@serializer ||= JSON
|
15
|
+
end
|
16
|
+
|
17
|
+
def job_queue_name
|
18
|
+
@job_queue_name ||= :events_subscribers
|
19
|
+
end
|
20
|
+
|
21
|
+
def store_options
|
22
|
+
@store_options ||= {}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/engine"
|
4
|
+
|
5
|
+
module ActiveEventStore
|
6
|
+
class Engine < ::Rails::Engine
|
7
|
+
config.active_event_store = ActiveEventStore.config
|
8
|
+
|
9
|
+
# Use before configuration hook to check for ActiveJob presence
|
10
|
+
ActiveSupport.on_load(:before_configuration) do
|
11
|
+
next warn "Active Job is not loaded. Active Event Store asynchrounous subscriptions won't worke" unless defined?(::ActiveJob)
|
12
|
+
|
13
|
+
require "active_event_store/subscriber_job"
|
14
|
+
require "active_event_store/rspec/have_enqueued_async_subscriber_for" if defined?(::RSpec::Matchers)
|
15
|
+
end
|
16
|
+
|
17
|
+
config.to_prepare do
|
18
|
+
# See https://railseventstore.org/docs/subscribe/#scheduling-async-handlers-after-commit
|
19
|
+
ActiveEventStore.event_store = RailsEventStore::Client.new(
|
20
|
+
dispatcher: RubyEventStore::ComposedDispatcher.new(
|
21
|
+
RailsEventStore::AfterCommitAsyncDispatcher.new(
|
22
|
+
scheduler: RailsEventStore::ActiveJobScheduler.new(
|
23
|
+
serializer: ActiveEventStore.config.serializer
|
24
|
+
)
|
25
|
+
),
|
26
|
+
RubyEventStore::Dispatcher.new
|
27
|
+
),
|
28
|
+
repository: ActiveEventStore.config.repository,
|
29
|
+
mapper: ActiveEventStore::Mapper.new(mapping: ActiveEventStore.mapping),
|
30
|
+
**ActiveEventStore.config.store_options
|
31
|
+
)
|
32
|
+
|
33
|
+
ActiveSupport.run_load_hooks(:active_event_store, ActiveEventStore)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveEventStore
|
4
|
+
# RES event wrapper
|
5
|
+
class Event < RubyEventStore::Event
|
6
|
+
RESERVED_ATTRIBUTES = %i[event_id type metadata].freeze
|
7
|
+
|
8
|
+
class << self
|
9
|
+
attr_writer :identifier
|
10
|
+
|
11
|
+
def identifier
|
12
|
+
return @identifier if instance_variable_defined?(:@identifier)
|
13
|
+
|
14
|
+
@identifier = name.underscore.tr("/", ".")
|
15
|
+
end
|
16
|
+
|
17
|
+
# define store readers
|
18
|
+
def attributes(*fields)
|
19
|
+
fields.each do |field|
|
20
|
+
raise ArgumentError, "#{field} is reserved" if RESERVED_ATTRIBUTES.include?(field)
|
21
|
+
|
22
|
+
defined_attributes << field
|
23
|
+
|
24
|
+
class_eval <<~CODE, __FILE__, __LINE__ + 1
|
25
|
+
def #{field}
|
26
|
+
data[:#{field}]
|
27
|
+
end
|
28
|
+
CODE
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def sync_attributes(*fields)
|
33
|
+
fields.each do |field|
|
34
|
+
raise ArgumentError, "#{field} is reserved" if RESERVED_ATTRIBUTES.include?(field)
|
35
|
+
|
36
|
+
defined_sync_attributes << field
|
37
|
+
|
38
|
+
attr_reader field
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def defined_attributes
|
43
|
+
return @defined_attributes if instance_variable_defined?(:@defined_attributes)
|
44
|
+
|
45
|
+
@defined_attributes =
|
46
|
+
if superclass.respond_to?(:defined_attributes)
|
47
|
+
superclass.defined_attributes.dup
|
48
|
+
else
|
49
|
+
[]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def defined_sync_attributes
|
54
|
+
return @defined_sync_attributes if instance_variable_defined?(:@defined_sync_attributes)
|
55
|
+
|
56
|
+
@defined_sync_attributes =
|
57
|
+
if superclass.respond_to?(:defined_sync_attributes)
|
58
|
+
superclass.defined_sync_attributes.dup
|
59
|
+
else
|
60
|
+
[]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def initialize(metadata: {}, event_id: nil, **params)
|
66
|
+
validate_attributes!(params)
|
67
|
+
extract_sync_attributes!(params)
|
68
|
+
super(**{event_id: event_id, metadata: metadata, data: params}.compact)
|
69
|
+
end
|
70
|
+
|
71
|
+
def type
|
72
|
+
self.class.identifier
|
73
|
+
end
|
74
|
+
|
75
|
+
alias_method :event_type, :type
|
76
|
+
|
77
|
+
def inspect
|
78
|
+
"#{self.class.name}<#{event_type}##{message_id}>, data: #{data}, metadata: #{metadata}"
|
79
|
+
end
|
80
|
+
|
81
|
+
# Has been removed from RES: https://github.com/RailsEventStore/rails_event_store/pull/726
|
82
|
+
def to_h
|
83
|
+
{
|
84
|
+
event_id: event_id,
|
85
|
+
metadata: metadata.to_h,
|
86
|
+
data: data,
|
87
|
+
type: event_type
|
88
|
+
}
|
89
|
+
end
|
90
|
+
|
91
|
+
protected
|
92
|
+
|
93
|
+
attr_writer :event_id
|
94
|
+
|
95
|
+
def validate_attributes!(params)
|
96
|
+
unknown_fields = params.keys.map(&:to_sym) - self.class.defined_attributes - self.class.defined_sync_attributes
|
97
|
+
unless unknown_fields.empty?
|
98
|
+
raise ArgumentError, "Unknown event attributes: #{unknown_fields.join(", ")}"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def extract_sync_attributes!(params)
|
103
|
+
params.keys.each do |key|
|
104
|
+
next unless self.class.defined_sync_attributes.include?(key.to_sym)
|
105
|
+
|
106
|
+
instance_variable_set(:"@#{key}", params.delete(key))
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveEventStore
|
4
|
+
using(Module.new {
|
5
|
+
refine Hash do
|
6
|
+
def symbolize_keys
|
7
|
+
RubyEventStore::TransformKeys.symbolize(self)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
})
|
11
|
+
|
12
|
+
# Custom mapper for RES events.
|
13
|
+
#
|
14
|
+
# See https://github.com/RailsEventStore/rails_event_store/blob/v0.35.0/ruby_event_store/lib/ruby_event_store/mappers/default.rb
|
15
|
+
class Mapper
|
16
|
+
def initialize(mapping:, serializer: ActiveEventStore.config.serializer)
|
17
|
+
@serializer = serializer
|
18
|
+
@mapping = mapping
|
19
|
+
end
|
20
|
+
|
21
|
+
def event_to_record(domain_event)
|
22
|
+
# lazily add type to mapping
|
23
|
+
# NOTE: use class name instead of a class to handle code reload
|
24
|
+
# in development (to avoid accessing orphaned classes)
|
25
|
+
mapping.register(domain_event.event_type, domain_event.class.name) unless mapping.exist?(domain_event.event_type)
|
26
|
+
|
27
|
+
RubyEventStore::Record.new(
|
28
|
+
event_id: domain_event.event_id,
|
29
|
+
metadata: serializer.dump(domain_event.metadata.to_h),
|
30
|
+
data: serializer.dump(domain_event.data),
|
31
|
+
event_type: domain_event.event_type,
|
32
|
+
timestamp: domain_event.timestamp,
|
33
|
+
valid_at: domain_event.valid_at
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
def record_to_event(record)
|
38
|
+
event_class = mapping.fetch(record.event_type) {
|
39
|
+
raise "Don't know how to deserialize event: \"#{record.event_type}\". " \
|
40
|
+
"Add explicit mapping: ActiveEventStore.mapping.register \"#{record.event_type}\", \"<Class Name>\""
|
41
|
+
}
|
42
|
+
|
43
|
+
Object.const_get(event_class).new(
|
44
|
+
**serializer.load(record.data).symbolize_keys,
|
45
|
+
metadata: serializer.load(record.metadata).symbolize_keys,
|
46
|
+
event_id: record.event_id
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :serializer, :mapping
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveEventStore
|
4
|
+
class Mapping
|
5
|
+
delegate :fetch, to: :data
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@data = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def register(type, class_name)
|
12
|
+
data[type] = class_name
|
13
|
+
end
|
14
|
+
|
15
|
+
def register_event(event_class)
|
16
|
+
register event_class.identifier, event_class.name
|
17
|
+
end
|
18
|
+
|
19
|
+
def exist?(type)
|
20
|
+
data.key?(type)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :data
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Do not fail if rspec-rails is not available
|
4
|
+
begin
|
5
|
+
require "rspec/rails"
|
6
|
+
require "rspec/rails/matchers/active_job"
|
7
|
+
rescue LoadError
|
8
|
+
warn "You must add `rspec-rails` to your project to use `have_enqueued_async_subscriber_for` matcher"
|
9
|
+
return
|
10
|
+
end
|
11
|
+
|
12
|
+
module ActiveEventStore
|
13
|
+
class HaveEnqueuedAsyncSubscriberFor < RSpec::Rails::Matchers::ActiveJob::HaveEnqueuedJob
|
14
|
+
class EventMatcher
|
15
|
+
include ::RSpec::Matchers::Composable
|
16
|
+
|
17
|
+
attr_reader :event
|
18
|
+
|
19
|
+
def initialize(event)
|
20
|
+
@event = event
|
21
|
+
end
|
22
|
+
|
23
|
+
def matches?(actual_serialized)
|
24
|
+
actual = ActiveEventStore.event_store.deserialize(
|
25
|
+
**actual_serialized,
|
26
|
+
serializer: ActiveEventStore.config.serializer
|
27
|
+
)
|
28
|
+
|
29
|
+
actual.event_type == event.event_type && data_matches?(actual.data)
|
30
|
+
end
|
31
|
+
|
32
|
+
def description
|
33
|
+
"be #{event.inspect}"
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def data_matches?(actual)
|
39
|
+
::RSpec::Matchers::BuiltIn::Match.new(event.data).matches?(actual)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def initialize(subscriber_class)
|
44
|
+
subscriber_job = ActiveEventStore::SubscriberJob.for(subscriber_class)
|
45
|
+
if subscriber_job.nil?
|
46
|
+
raise(
|
47
|
+
RSpec::Expectations::ExpectationNotMetError,
|
48
|
+
"No such async subscriber: #{subscriber_class.name}"
|
49
|
+
)
|
50
|
+
end
|
51
|
+
super(subscriber_job)
|
52
|
+
on_queue("events_subscribers")
|
53
|
+
end
|
54
|
+
|
55
|
+
def with(event)
|
56
|
+
super(EventMatcher.new(event))
|
57
|
+
end
|
58
|
+
|
59
|
+
def matches?(block)
|
60
|
+
raise ArgumentError, "have_enqueued_async_subscriber_for only supports block expectations" unless block.is_a?(Proc)
|
61
|
+
# Make sure that there is a transaction
|
62
|
+
super(proc { ActiveRecord::Base.transaction(&block) })
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
RSpec.configure do |config|
|
68
|
+
config.include(Module.new {
|
69
|
+
def have_enqueued_async_subscriber_for(*args)
|
70
|
+
ActiveEventStore::HaveEnqueuedAsyncSubscriberFor.new(*args)
|
71
|
+
end
|
72
|
+
})
|
73
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveEventStore
|
4
|
+
class HavePublishedEvent < RSpec::Matchers::BuiltIn::BaseMatcher
|
5
|
+
attr_reader :event_class, :event_store, :attributes
|
6
|
+
|
7
|
+
def initialize(event_class)
|
8
|
+
@event_class = event_class
|
9
|
+
@event_store = ActiveEventStore.event_store
|
10
|
+
set_expected_number(:exactly, 1)
|
11
|
+
end
|
12
|
+
|
13
|
+
def with_store(store)
|
14
|
+
@event_store = store
|
15
|
+
self
|
16
|
+
end
|
17
|
+
|
18
|
+
def with(attributes)
|
19
|
+
@attributes = attributes
|
20
|
+
self
|
21
|
+
end
|
22
|
+
|
23
|
+
def exactly(count)
|
24
|
+
set_expected_number(:exactly, count)
|
25
|
+
self
|
26
|
+
end
|
27
|
+
|
28
|
+
def at_least(count)
|
29
|
+
set_expected_number(:at_least, count)
|
30
|
+
self
|
31
|
+
end
|
32
|
+
|
33
|
+
def at_most(count)
|
34
|
+
set_expected_number(:at_most, count)
|
35
|
+
self
|
36
|
+
end
|
37
|
+
|
38
|
+
def times
|
39
|
+
self
|
40
|
+
end
|
41
|
+
|
42
|
+
def once
|
43
|
+
exactly(:once)
|
44
|
+
end
|
45
|
+
|
46
|
+
def twice
|
47
|
+
exactly(:twice)
|
48
|
+
end
|
49
|
+
|
50
|
+
def thrice
|
51
|
+
exactly(:thrice)
|
52
|
+
end
|
53
|
+
|
54
|
+
def supports_block_expectations?
|
55
|
+
true
|
56
|
+
end
|
57
|
+
|
58
|
+
def matches?(block)
|
59
|
+
raise ArgumentError, "have_published_event only supports block expectations" unless block.is_a?(Proc)
|
60
|
+
|
61
|
+
original_count = event_store.read.count
|
62
|
+
block.call
|
63
|
+
new_count = event_store.read.count - original_count
|
64
|
+
in_block_events = new_count.positive? ? event_store.read.backward.limit(new_count).to_a :
|
65
|
+
[]
|
66
|
+
|
67
|
+
@matching_events, @unmatching_events =
|
68
|
+
in_block_events.partition do |actual_event|
|
69
|
+
(event_class.identifier == actual_event.event_type) &&
|
70
|
+
(attributes.nil? || attributes_match?(actual_event))
|
71
|
+
end
|
72
|
+
|
73
|
+
@matching_count = @matching_events.size
|
74
|
+
|
75
|
+
case @expectation_type
|
76
|
+
when :exactly then @expected_number == @matching_count
|
77
|
+
when :at_most then @expected_number >= @matching_count
|
78
|
+
when :at_least then @expected_number <= @matching_count
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def attributes_match?(event)
|
85
|
+
RSpec::Matchers::BuiltIn::HaveAttributes.new(attributes).matches?(event)
|
86
|
+
end
|
87
|
+
|
88
|
+
def set_expected_number(relativity, count)
|
89
|
+
@expectation_type = relativity
|
90
|
+
@expected_number =
|
91
|
+
case count
|
92
|
+
when :once then 1
|
93
|
+
when :twice then 2
|
94
|
+
when :thrice then 3
|
95
|
+
else Integer(count)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def failure_message
|
100
|
+
(+"expected to publish #{event_class.identifier} event").tap do |msg|
|
101
|
+
msg << " #{message_expectation_modifier}, but"
|
102
|
+
|
103
|
+
if @unmatching_events.any?
|
104
|
+
msg << " published the following events:"
|
105
|
+
@unmatching_events.each do |unmatching_event|
|
106
|
+
msg << "\n #{unmatching_event.inspect}"
|
107
|
+
end
|
108
|
+
else
|
109
|
+
msg << " haven't published anything"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def failure_message_when_negated
|
115
|
+
"expected not to publish #{event_class.identifier} event"
|
116
|
+
end
|
117
|
+
|
118
|
+
def message_expectation_modifier
|
119
|
+
number_modifier = @expected_number == 1 ? "once" : "#{@expected_number} times"
|
120
|
+
case @expectation_type
|
121
|
+
when :exactly then "exactly #{number_modifier}"
|
122
|
+
when :at_most then "at most #{number_modifier}"
|
123
|
+
when :at_least then "at least #{number_modifier}"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
RSpec.configure do |config|
|
130
|
+
config.include(Module.new do
|
131
|
+
def have_published_event(*args)
|
132
|
+
ActiveEventStore::HavePublishedEvent.new(*args)
|
133
|
+
end
|
134
|
+
end)
|
135
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveEventStore
|
4
|
+
# Base job for async subscribers
|
5
|
+
class SubscriberJob < ActiveJob::Base
|
6
|
+
class << self
|
7
|
+
attr_accessor :subscriber
|
8
|
+
|
9
|
+
def from(callable)
|
10
|
+
if callable.is_a?(Proc) || callable.name.nil?
|
11
|
+
raise ArgumentError, "Anonymous subscribers (blocks/procs/lambdas or anonymous modules) " \
|
12
|
+
"could not be asynchronous (use sync: true)"
|
13
|
+
end
|
14
|
+
|
15
|
+
raise ArgumentError, "Async subscriber must be a module/class, not instance" unless callable.is_a?(Module)
|
16
|
+
|
17
|
+
if callable.const_defined?("SubscriberJob", false)
|
18
|
+
callable.const_get("SubscriberJob", false)
|
19
|
+
else
|
20
|
+
callable.const_set(
|
21
|
+
"SubscriberJob",
|
22
|
+
Class.new(self).tap do |job|
|
23
|
+
queue_as ActiveEventStore.config.job_queue_name
|
24
|
+
|
25
|
+
job.subscriber = callable
|
26
|
+
end
|
27
|
+
)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def for(callable)
|
32
|
+
raise ArgumentError, "Async subscriber must be a module/class" unless callable.is_a?(Module)
|
33
|
+
|
34
|
+
callable.const_defined?("SubscriberJob", false) ?
|
35
|
+
callable.const_get("SubscriberJob", false) :
|
36
|
+
nil
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def perform(payload)
|
41
|
+
event = event_store.deserialize(**payload, serializer: ActiveEventStore.config.serializer)
|
42
|
+
|
43
|
+
event_store.with_metadata(**event.metadata.to_h) do
|
44
|
+
subscriber.call(event)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def subscriber
|
51
|
+
self.class.subscriber
|
52
|
+
end
|
53
|
+
|
54
|
+
def event_store
|
55
|
+
ActiveEventStore.event_store
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
data/lib/active_event_store.rb
CHANGED
@@ -1,4 +1,75 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "rails_event_store"
|
4
|
+
|
3
5
|
require "active_event_store/version"
|
4
|
-
|
6
|
+
|
7
|
+
require "active_event_store/config"
|
8
|
+
require "active_event_store/event"
|
9
|
+
require "active_event_store/mapping"
|
10
|
+
require "active_event_store/mapper"
|
11
|
+
|
12
|
+
require "active_event_store/rspec" if defined?(RSpec::Core)
|
13
|
+
|
14
|
+
module ActiveEventStore
|
15
|
+
class << self
|
16
|
+
# Underlying RailsEventStore
|
17
|
+
attr_accessor :event_store
|
18
|
+
|
19
|
+
def mapping
|
20
|
+
@mapping ||= Mapping.new
|
21
|
+
end
|
22
|
+
|
23
|
+
def config
|
24
|
+
@config ||= Config.new
|
25
|
+
end
|
26
|
+
|
27
|
+
def subscribe(subscriber = nil, to: nil, sync: false, &block)
|
28
|
+
subscriber ||= block
|
29
|
+
|
30
|
+
to ||= infer_event_from_subscriber(subscriber) if subscriber.is_a?(Module)
|
31
|
+
|
32
|
+
if to.nil?
|
33
|
+
raise ArgumentError, "Couldn't infer event from subscriber. " \
|
34
|
+
"Please, specify event using `to:` option"
|
35
|
+
end
|
36
|
+
|
37
|
+
identifier =
|
38
|
+
if to.is_a?(Class) && ActiveEventStore::Event >= to
|
39
|
+
# register event
|
40
|
+
mapping.register_event to
|
41
|
+
|
42
|
+
to.identifier
|
43
|
+
else
|
44
|
+
to
|
45
|
+
end
|
46
|
+
|
47
|
+
subscriber = SubscriberJob.from(subscriber) unless sync
|
48
|
+
|
49
|
+
event_store.subscribe subscriber, to: [identifier]
|
50
|
+
end
|
51
|
+
|
52
|
+
def publish(event, **options)
|
53
|
+
event_store.publish event, **options
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def infer_event_from_subscriber(subscriber)
|
59
|
+
event_class_name = subscriber.name.split("::").yield_self do |parts|
|
60
|
+
# handle explicti top-level name, e.g. ::Some::Event
|
61
|
+
parts.shift if parts.first.empty?
|
62
|
+
# drop last part – it's a unique subscriber name
|
63
|
+
parts.pop
|
64
|
+
|
65
|
+
parts.last.sub!(/^On/, "")
|
66
|
+
|
67
|
+
parts.join("::")
|
68
|
+
end
|
69
|
+
|
70
|
+
event_class_name.safe_constantize
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
require "active_event_store/engine"
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_event_store
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Vladimir Dementyev
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-09-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails_event_store
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 2.1.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 2.1.0
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: bundler
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -53,17 +67,17 @@ dependencies:
|
|
53
67
|
- !ruby/object:Gem::Version
|
54
68
|
version: '13.0'
|
55
69
|
- !ruby/object:Gem::Dependency
|
56
|
-
name: rspec
|
70
|
+
name: rspec-rails
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
58
72
|
requirements:
|
59
|
-
- - "
|
73
|
+
- - ">="
|
60
74
|
- !ruby/object:Gem::Version
|
61
75
|
version: '3.8'
|
62
76
|
type: :development
|
63
77
|
prerelease: false
|
64
78
|
version_requirements: !ruby/object:Gem::Requirement
|
65
79
|
requirements:
|
66
|
-
- - "
|
80
|
+
- - ">="
|
67
81
|
- !ruby/object:Gem::Version
|
68
82
|
version: '3.8'
|
69
83
|
description: Wrapper over Rails Event Store with conventions and transparent Rails
|
@@ -78,7 +92,15 @@ files:
|
|
78
92
|
- LICENSE.txt
|
79
93
|
- README.md
|
80
94
|
- lib/active_event_store.rb
|
81
|
-
- lib/active_event_store/
|
95
|
+
- lib/active_event_store/config.rb
|
96
|
+
- lib/active_event_store/engine.rb
|
97
|
+
- lib/active_event_store/event.rb
|
98
|
+
- lib/active_event_store/mapper.rb
|
99
|
+
- lib/active_event_store/mapping.rb
|
100
|
+
- lib/active_event_store/rspec.rb
|
101
|
+
- lib/active_event_store/rspec/have_enqueued_async_subscriber_for.rb
|
102
|
+
- lib/active_event_store/rspec/have_published_event.rb
|
103
|
+
- lib/active_event_store/subscriber_job.rb
|
82
104
|
- lib/active_event_store/version.rb
|
83
105
|
homepage: http://github.com/palkan/active_event_store
|
84
106
|
licenses:
|
@@ -89,7 +111,7 @@ metadata:
|
|
89
111
|
documentation_uri: http://github.com/palkan/active_event_store
|
90
112
|
homepage_uri: http://github.com/palkan/active_event_store
|
91
113
|
source_code_uri: http://github.com/palkan/active_event_store
|
92
|
-
post_install_message:
|
114
|
+
post_install_message:
|
93
115
|
rdoc_options: []
|
94
116
|
require_paths:
|
95
117
|
- lib
|
@@ -97,15 +119,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
119
|
requirements:
|
98
120
|
- - ">="
|
99
121
|
- !ruby/object:Gem::Version
|
100
|
-
version: '2.
|
122
|
+
version: '2.6'
|
101
123
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
102
124
|
requirements:
|
103
125
|
- - ">="
|
104
126
|
- !ruby/object:Gem::Version
|
105
127
|
version: '0'
|
106
128
|
requirements: []
|
107
|
-
rubygems_version: 3.
|
108
|
-
signing_key:
|
129
|
+
rubygems_version: 3.2.15
|
130
|
+
signing_key:
|
109
131
|
specification_version: 4
|
110
132
|
summary: Rails Event Store in a more Rails way
|
111
133
|
test_files: []
|