active_event_store 0.0.1 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
[![Gem Version](https://badge.fury.io/rb/active_event_store.svg)](https://rubygems.org/gems/active_event_store) [![Build](https://github.com/palkan/active_event_store/workflows/Build/badge.svg)](https://github.com/palkan/active_event_store/actions)
|
2
|
-
[![JRuby Build](https://github.com/palkan/active_event_store/workflows/JRuby%20Build/badge.svg)](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: []
|