yes-core 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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
- data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
- data/lib/yes/core/aggregate/draftable.rb +205 -0
- data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
- data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
- data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
- data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
- data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
- data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
- data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
- data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
- data/lib/yes/core/aggregate/has_read_model.rb +169 -0
- data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
- data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
- data/lib/yes/core/aggregate.rb +404 -0
- data/lib/yes/core/authentication_error.rb +8 -0
- data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
- data/lib/yes/core/authorization/command_authorizer.rb +40 -0
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
- data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
- data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
- data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
- data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
- data/lib/yes/core/command.rb +35 -0
- data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
- data/lib/yes/core/command_handling/command_executor.rb +171 -0
- data/lib/yes/core/command_handling/command_handler.rb +124 -0
- data/lib/yes/core/command_handling/event_publisher.rb +189 -0
- data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
- data/lib/yes/core/command_handling/guard_runner.rb +76 -0
- data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
- data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
- data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
- data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
- data/lib/yes/core/command_handling/state_updater.rb +113 -0
- data/lib/yes/core/commands/bus.rb +46 -0
- data/lib/yes/core/commands/group.rb +135 -0
- data/lib/yes/core/commands/group_response.rb +13 -0
- data/lib/yes/core/commands/helper.rb +126 -0
- data/lib/yes/core/commands/notifier.rb +65 -0
- data/lib/yes/core/commands/processor.rb +137 -0
- data/lib/yes/core/commands/response.rb +63 -0
- data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
- data/lib/yes/core/commands/stateless/group_response.rb +15 -0
- data/lib/yes/core/commands/stateless/handler.rb +292 -0
- data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
- data/lib/yes/core/commands/stateless/response.rb +14 -0
- data/lib/yes/core/commands/stateless/subject.rb +41 -0
- data/lib/yes/core/commands/validator.rb +28 -0
- data/lib/yes/core/configuration.rb +432 -0
- data/lib/yes/core/data_decryptor.rb +59 -0
- data/lib/yes/core/data_encryptor.rb +60 -0
- data/lib/yes/core/encryption_metadata.rb +33 -0
- data/lib/yes/core/error.rb +14 -0
- data/lib/yes/core/error_messages.rb +37 -0
- data/lib/yes/core/event.rb +222 -0
- data/lib/yes/core/event_class_resolver.rb +40 -0
- data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
- data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
- data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
- data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
- data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
- data/lib/yes/core/middlewares/encryptor.rb +48 -0
- data/lib/yes/core/middlewares/timestamp.rb +29 -0
- data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
- data/lib/yes/core/models/application_record.rb +9 -0
- data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
- data/lib/yes/core/open_telemetry/trackable.rb +101 -0
- data/lib/yes/core/payload_store/base.rb +33 -0
- data/lib/yes/core/payload_store/errors.rb +13 -0
- data/lib/yes/core/payload_store/lookup.rb +44 -0
- data/lib/yes/core/process_managers/access_token_client.rb +107 -0
- data/lib/yes/core/process_managers/base.rb +40 -0
- data/lib/yes/core/process_managers/command_runner.rb +109 -0
- data/lib/yes/core/process_managers/service_client.rb +57 -0
- data/lib/yes/core/process_managers/state.rb +118 -0
- data/lib/yes/core/railtie.rb +58 -0
- data/lib/yes/core/read_model/builder.rb +267 -0
- data/lib/yes/core/read_model/event_handler.rb +64 -0
- data/lib/yes/core/read_model/filter.rb +118 -0
- data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
- data/lib/yes/core/serializer.rb +21 -0
- data/lib/yes/core/subscriptions.rb +94 -0
- data/lib/yes/core/test_support/event_helpers.rb +27 -0
- data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
- data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
- data/lib/yes/core/test_support/test_helper.rb +27 -0
- data/lib/yes/core/test_support.rb +5 -0
- data/lib/yes/core/transaction_details.rb +90 -0
- data/lib/yes/core/type_lookup.rb +88 -0
- data/lib/yes/core/types.rb +110 -0
- data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
- data/lib/yes/core/utils/caller_utils.rb +37 -0
- data/lib/yes/core/utils/command_utils.rb +226 -0
- data/lib/yes/core/utils/error_notifier.rb +101 -0
- data/lib/yes/core/utils/event_name_resolver.rb +67 -0
- data/lib/yes/core/utils/exponential_retrier.rb +180 -0
- data/lib/yes/core/utils/hash_utils.rb +63 -0
- data/lib/yes/core/version.rb +7 -0
- data/lib/yes/core.rb +85 -0
- data/lib/yes.rb +0 -0
- metadata +324 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Handles publishing events with revision checks
|
|
7
|
+
class EventPublisher
|
|
8
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
9
|
+
|
|
10
|
+
# Value object containing aggregate data needed for event publication
|
|
11
|
+
AggregateEventPublicationData = Struct.new(:id, :context, :name, :revision, keyword_init: true) do
|
|
12
|
+
def self.from_aggregate(aggregate)
|
|
13
|
+
new(
|
|
14
|
+
id: aggregate.id,
|
|
15
|
+
context: aggregate.class.context,
|
|
16
|
+
name: aggregate.class.name.split('::')[1],
|
|
17
|
+
revision: lambda {
|
|
18
|
+
if aggregate.class.read_model_enabled?
|
|
19
|
+
aggregate.reload.revision
|
|
20
|
+
else
|
|
21
|
+
# When read models are disabled, get revision directly from event stream
|
|
22
|
+
begin
|
|
23
|
+
latest = aggregate.latest_event
|
|
24
|
+
latest ? latest.stream_revision : -1
|
|
25
|
+
rescue PgEventstore::StreamNotFoundError
|
|
26
|
+
# Stream doesn't exist yet - this is the first event
|
|
27
|
+
-1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param command [Object] The command instance
|
|
36
|
+
# @param aggregate_data [AggregateEventPublicationData] The aggregate publication data
|
|
37
|
+
# @param accessed_external_aggregates [Array<Hash>] List of accessed external aggregates with their revisions
|
|
38
|
+
# @param event_name [String, nil] Optional explicit event name to use
|
|
39
|
+
def initialize(command:, aggregate_data:, accessed_external_aggregates:, event_name: nil)
|
|
40
|
+
@command = command
|
|
41
|
+
@aggregate_data = aggregate_data
|
|
42
|
+
@accessed_external_aggregates = accessed_external_aggregates
|
|
43
|
+
@event_name = event_name
|
|
44
|
+
@command_utilities = Utils::CommandUtils.new(
|
|
45
|
+
context: aggregate_data.context,
|
|
46
|
+
aggregate: aggregate_data.name,
|
|
47
|
+
aggregate_id: aggregate_data.id
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Publishes the event after verifying revisions
|
|
52
|
+
#
|
|
53
|
+
# @return [PgEventstore::Event] The published event
|
|
54
|
+
# @raise [PgEventstore::WrongExpectedRevisionError] When revisions don't match
|
|
55
|
+
def call
|
|
56
|
+
verify_external_revisions!
|
|
57
|
+
publish_event
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
otl_trackable(
|
|
61
|
+
:call,
|
|
62
|
+
Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Publish Event', span_kind: :producer, track_sql: true)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# @return [Object] The command instance
|
|
68
|
+
attr_reader :command
|
|
69
|
+
# @return [AggregateEventPublicationData] The aggregate publication data
|
|
70
|
+
attr_reader :aggregate_data
|
|
71
|
+
# @return [Array<Hash>] List of accessed external aggregates with their revisions
|
|
72
|
+
attr_reader :accessed_external_aggregates
|
|
73
|
+
# @return [String, nil] The explicit event name to use
|
|
74
|
+
attr_reader :event_name
|
|
75
|
+
# @return [CommandUtils] The command utilities instance
|
|
76
|
+
attr_reader :command_utilities
|
|
77
|
+
|
|
78
|
+
delegate :payload, :origin, :batch_id, :metadata, to: :command
|
|
79
|
+
|
|
80
|
+
# Publishes the event to the event store
|
|
81
|
+
#
|
|
82
|
+
# @return [PgEventstore::Event] The published event
|
|
83
|
+
def publish_event
|
|
84
|
+
revision = aggregate_data.revision.call
|
|
85
|
+
expected_revision = revision == -1 ? :no_stream : revision
|
|
86
|
+
|
|
87
|
+
event = event_with_metadata
|
|
88
|
+
otl_record_event_data(event)
|
|
89
|
+
|
|
90
|
+
PgEventstore.client.append_to_stream(
|
|
91
|
+
command_utilities.build_stream(metadata:),
|
|
92
|
+
event,
|
|
93
|
+
options: { expected_revision: }
|
|
94
|
+
).tap { otl_record_response(_1) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Verifies revisions of all accessed external aggregates
|
|
98
|
+
#
|
|
99
|
+
# @return [void]
|
|
100
|
+
# @raise [PgEventstore::WrongExpectedRevisionError] When revisions don't match
|
|
101
|
+
def verify_external_revisions!
|
|
102
|
+
accessed_external_aggregates.each do |aggregate_data|
|
|
103
|
+
stream = command_utilities.build_stream(
|
|
104
|
+
context: aggregate_data[:context],
|
|
105
|
+
name: aggregate_data[:name],
|
|
106
|
+
id: aggregate_data[:id]
|
|
107
|
+
)
|
|
108
|
+
expected_revision = command_utilities.stream_revision(stream)
|
|
109
|
+
aggregate_revision = aggregate_data[:revision].call
|
|
110
|
+
normalized_revision = aggregate_revision == -1 ? :no_stream : aggregate_revision
|
|
111
|
+
|
|
112
|
+
next if normalized_revision == expected_revision
|
|
113
|
+
|
|
114
|
+
raise PgEventstore::WrongExpectedRevisionError.new(
|
|
115
|
+
revision: aggregate_revision,
|
|
116
|
+
expected_revision:,
|
|
117
|
+
stream:
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Builds an event with metadata from the command
|
|
123
|
+
#
|
|
124
|
+
# @return [PgEventstore::Event] The event with metadata
|
|
125
|
+
def event_with_metadata
|
|
126
|
+
command_utilities.build_event(
|
|
127
|
+
command_name: command.class.name.split('::')[-2].underscore.to_sym,
|
|
128
|
+
payload:,
|
|
129
|
+
metadata: event_metadata
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Builds the event metadata
|
|
134
|
+
#
|
|
135
|
+
# @return [Hash] The event metadata
|
|
136
|
+
def event_metadata
|
|
137
|
+
meta = {}
|
|
138
|
+
meta['origin'] = origin if origin.present?
|
|
139
|
+
meta['batch_id'] = batch_id if batch_id.present?
|
|
140
|
+
meta['yes-dsl'] = true
|
|
141
|
+
meta.merge!(metadata) if metadata.present?
|
|
142
|
+
|
|
143
|
+
meta[:otl_contexts][:publisher] = self.class.propagate_context(service_name: true) if meta[:otl_contexts].present?
|
|
144
|
+
|
|
145
|
+
meta
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def otl_record_event_data(event)
|
|
149
|
+
self.class.current_span&.add_attributes(
|
|
150
|
+
{
|
|
151
|
+
'event.type' => event.type,
|
|
152
|
+
'event.data' => event.data.to_json,
|
|
153
|
+
'event.metadata' => event.metadata.to_json
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def otl_record_response(result)
|
|
159
|
+
if ENV['STATSD_ADDR'].present?
|
|
160
|
+
StatsD.increment(
|
|
161
|
+
'events_processing_total',
|
|
162
|
+
tags: {
|
|
163
|
+
service: Rails.application.class.module_parent.name,
|
|
164
|
+
source: "#{Rails.application.class.module_parent.name}-#{result.type}",
|
|
165
|
+
target: "#{Rails.application.class.module_parent.name}-#{result.type}",
|
|
166
|
+
type: 'producer',
|
|
167
|
+
event: result.type
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
self.class.current_span&.status = ::OpenTelemetry::Trace::Status.ok
|
|
173
|
+
self.class.current_span&.add_event(
|
|
174
|
+
'Event Published to PgEventstore',
|
|
175
|
+
timestamp: result.created_at,
|
|
176
|
+
attributes: {
|
|
177
|
+
'event.type' => result.type,
|
|
178
|
+
'event.link_id' => result.link_id || '',
|
|
179
|
+
'global_position' => result.global_position,
|
|
180
|
+
'stream' => result.stream.to_json,
|
|
181
|
+
'stream.revision' => result.stream_revision,
|
|
182
|
+
'timestamp_ms' => (result.created_at.to_f * 1000).to_i
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Base class for evaluating guards on command attributes
|
|
7
|
+
class GuardEvaluator
|
|
8
|
+
class TransitionError < Yes::Core::Error; end
|
|
9
|
+
class InvalidTransition < TransitionError; end
|
|
10
|
+
class NoChangeTransition < TransitionError; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# @return [Hash<Symbol, Proc>] Hash of registered guards with names as keys and blocks as values
|
|
14
|
+
def guards
|
|
15
|
+
@guards ||= {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Defines a new guard with a name and evaluation block
|
|
19
|
+
#
|
|
20
|
+
# @param name [Symbol] Name of the guard
|
|
21
|
+
# @param error_extra [Hash, Proc] The extra information to be added to the error message payload
|
|
22
|
+
# @yield Block to evaluate the guard condition
|
|
23
|
+
# @yieldreturn [Boolean] True if the guard passes, false otherwise
|
|
24
|
+
# @return [void]
|
|
25
|
+
def guard(name, error_extra: {}, &block)
|
|
26
|
+
guards[name] = { block:, error_extra: }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param payload [Hash] The command payload
|
|
31
|
+
# @param metadata [Hash] The command metadata
|
|
32
|
+
# @param aggregate [Yes::Core::Aggregate] The aggregate instance
|
|
33
|
+
# @param command_name [Symbol] The command name
|
|
34
|
+
def initialize(payload:, metadata:, aggregate:, command_name:)
|
|
35
|
+
@raw_payload = payload
|
|
36
|
+
@raw_metadata = metadata
|
|
37
|
+
@aggregate = aggregate
|
|
38
|
+
@aggregate_tracker = AggregateTracker.new
|
|
39
|
+
@command_name = command_name
|
|
40
|
+
@payload = PayloadProxy.new(
|
|
41
|
+
raw_payload:,
|
|
42
|
+
raw_metadata:,
|
|
43
|
+
context: aggregate.class.context,
|
|
44
|
+
aggregate_tracker:,
|
|
45
|
+
parent_aggregates: aggregate.class.parent_aggregates
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Evaluates all registered guards
|
|
50
|
+
#
|
|
51
|
+
# @return [void]
|
|
52
|
+
# @raise [InvalidTransition] When a guard fails with an invalid transition
|
|
53
|
+
# @raise [NoChangeTransition] When a guard fails with a no change transition
|
|
54
|
+
def call
|
|
55
|
+
self.class.guards.each do |name, guard_data|
|
|
56
|
+
evaluate_guard(name, error_extra: guard_data[:error_extra], block: guard_data[:block])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Array<Hash>] List of accessed external aggregates with their revisions
|
|
61
|
+
delegate :accessed_external_aggregates, to: :aggregate_tracker
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :raw_payload, :raw_metadata, :payload, :aggregate, :aggregate_tracker, :command_name
|
|
66
|
+
|
|
67
|
+
# Evaluates a single guard and raises appropriate error if it fails
|
|
68
|
+
#
|
|
69
|
+
# @param name [Symbol] The name of the guard
|
|
70
|
+
# @param error_extra [Hash, Proc] The extra information to be added to the error message payload
|
|
71
|
+
# @param block [Proc] The guard block to evaluate
|
|
72
|
+
# @return [void]
|
|
73
|
+
# @raise [InvalidTransition] When the guard fails with an invalid transition
|
|
74
|
+
# @raise [NoChangeTransition] When the guard fails with a no change transition
|
|
75
|
+
def evaluate_guard(name, block:, error_extra: {})
|
|
76
|
+
result = evaluate_with_locale(&block)
|
|
77
|
+
return if result
|
|
78
|
+
|
|
79
|
+
extra = error_extra.respond_to?(:call) ? evaluate_with_locale(&error_extra) : error_extra
|
|
80
|
+
|
|
81
|
+
error_class = name == :no_change ? NoChangeTransition : InvalidTransition
|
|
82
|
+
raise error_class.new(error_message(name), extra:)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def value_changed?(val1, val2)
|
|
86
|
+
return val1 != val2 unless val1.is_a?(Hash) && val2.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
val1.with_indifferent_access != val2.with_indifferent_access
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Looks up the error message for a guard from I18n translations
|
|
92
|
+
#
|
|
93
|
+
# @param guard_name [Symbol] The name of the guard
|
|
94
|
+
# @return [String] The error message
|
|
95
|
+
def error_message(guard_name)
|
|
96
|
+
context_name = aggregate.class.context
|
|
97
|
+
aggregate_name = aggregate.class.aggregate
|
|
98
|
+
|
|
99
|
+
Yes::Core::ErrorMessages.guard_error(context_name, aggregate_name, command_name.to_s, guard_name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Handles method missing to delegate attribute calls to the current aggregate
|
|
103
|
+
#
|
|
104
|
+
# @param method_name [Symbol] The method name being called
|
|
105
|
+
# @yield Optional block passed to the method (unused)
|
|
106
|
+
# @yieldreturn [void]
|
|
107
|
+
# @return [Object] The result of calling the method on the current aggregate
|
|
108
|
+
def method_missing(method_name, *, &)
|
|
109
|
+
if aggregate.respond_to?(method_name)
|
|
110
|
+
result = aggregate.public_send(method_name, *, &)
|
|
111
|
+
track_external_aggregate(method_name, result) if aggregate_attribute?(method_name)
|
|
112
|
+
result
|
|
113
|
+
else
|
|
114
|
+
super
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Checks if method is defined on the current aggregate
|
|
119
|
+
#
|
|
120
|
+
# @param method_name [Symbol] The method name to check
|
|
121
|
+
# @param include_private [Boolean] Whether to include private methods
|
|
122
|
+
# @return [Boolean] True if method exists
|
|
123
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
124
|
+
aggregate.respond_to?(method_name, include_private) || super
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Checks if the given method is an aggregate attribute
|
|
128
|
+
#
|
|
129
|
+
# @param method_name [Symbol] The method name to check
|
|
130
|
+
# @return [Boolean] True if the method is an aggregate attribute
|
|
131
|
+
def aggregate_attribute?(method_name)
|
|
132
|
+
aggregate.class.attributes[method_name] == :aggregate
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Tracks an external aggregate access
|
|
136
|
+
#
|
|
137
|
+
# @param attribute_name [Symbol] The attribute name
|
|
138
|
+
# @param instance [Object] The aggregate instance
|
|
139
|
+
# @return [void]
|
|
140
|
+
def track_external_aggregate(attribute_name, instance)
|
|
141
|
+
return unless instance
|
|
142
|
+
|
|
143
|
+
aggregate_tracker.track(
|
|
144
|
+
attribute_name: attribute_name.to_s.camelize,
|
|
145
|
+
id: instance.id,
|
|
146
|
+
revision: -> { instance.reload.revision },
|
|
147
|
+
context: aggregate.class.context
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Evaluates a block with the locale from payload if present
|
|
152
|
+
#
|
|
153
|
+
# @yield Block to be evaluated
|
|
154
|
+
# @return [Object] Result of block evaluation
|
|
155
|
+
def evaluate_with_locale(&block)
|
|
156
|
+
if raw_payload[:locale].present?
|
|
157
|
+
I18n.with_locale(raw_payload[:locale]) { instance_eval(&block) }
|
|
158
|
+
else
|
|
159
|
+
instance_eval(&block)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Evaluates guards for commands and manages command-specific errors on aggregates
|
|
7
|
+
# Handles the decision of whether to skip guards and properly sets/clears error states
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# runner = GuardRunner.new(aggregate)
|
|
11
|
+
# evaluator = runner.call(cmd, command_name, guard_evaluator_class, skip_guards: false)
|
|
12
|
+
#
|
|
13
|
+
class GuardRunner
|
|
14
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
15
|
+
|
|
16
|
+
# Initializes a new GuardRunner
|
|
17
|
+
#
|
|
18
|
+
# @param aggregate [Yes::Core::Aggregate] The aggregate instance for error management
|
|
19
|
+
def initialize(aggregate)
|
|
20
|
+
@aggregate = aggregate
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Evaluates guards for the command
|
|
24
|
+
#
|
|
25
|
+
# @param cmd [Yes::Core::Command] The command to be handled
|
|
26
|
+
# @param command_name [Symbol] The name of the command being executed
|
|
27
|
+
# @param guard_evaluator_class [Class] The guard evaluator class
|
|
28
|
+
# @param skip_guards [Boolean] Whether to skip guard evaluation
|
|
29
|
+
# @return [GuardEvaluator, nil] The guard evaluator instance or nil if guards skipped
|
|
30
|
+
# @raise [GuardEvaluator::InvalidTransition] When the transition is invalid
|
|
31
|
+
# @raise [GuardEvaluator::NoChangeTransition] When no change would occur
|
|
32
|
+
# @raise [Yes::Core::Command::Invalid] When the command is invalid
|
|
33
|
+
def call(cmd, command_name, guard_evaluator_class, skip_guards:)
|
|
34
|
+
if skip_guards
|
|
35
|
+
clear_command_error(command_name)
|
|
36
|
+
return nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
evaluator = guard_evaluator_class.new(
|
|
40
|
+
payload: cmd.payload,
|
|
41
|
+
metadata: cmd.metadata,
|
|
42
|
+
aggregate: aggregate,
|
|
43
|
+
command_name: command_name
|
|
44
|
+
)
|
|
45
|
+
evaluator.call
|
|
46
|
+
|
|
47
|
+
clear_command_error(command_name)
|
|
48
|
+
|
|
49
|
+
evaluator
|
|
50
|
+
rescue GuardEvaluator::InvalidTransition,
|
|
51
|
+
GuardEvaluator::NoChangeTransition,
|
|
52
|
+
Yes::Core::Command::Invalid => e
|
|
53
|
+
aggregate.send(:"#{command_name.to_s.underscore}_error=", e.message)
|
|
54
|
+
raise e
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
otl_trackable(
|
|
58
|
+
:call,
|
|
59
|
+
Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Evaluate guards')
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
attr_reader :aggregate
|
|
65
|
+
|
|
66
|
+
# Clears command-specific error on aggregate
|
|
67
|
+
#
|
|
68
|
+
# @param command_name [Symbol, String] The command name
|
|
69
|
+
# @return [void]
|
|
70
|
+
def clear_command_error(command_name)
|
|
71
|
+
aggregate.send(:"#{command_name.to_s.underscore}_error=", nil)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module CommandHandling
|
|
6
|
+
# Provides proxy access to command payload with dynamic aggregate resolution
|
|
7
|
+
class PayloadProxy
|
|
8
|
+
# @param raw_payload [Hash] The raw command payload
|
|
9
|
+
# @param raw_metadata [Hash, nil] The raw command metadata (optional)
|
|
10
|
+
# @param context [String] The context name
|
|
11
|
+
# @param aggregate_tracker [AggregateTracker, nil] The tracker instance (optional)
|
|
12
|
+
def initialize(raw_payload:, context:, parent_aggregates:, raw_metadata: nil, aggregate_tracker: nil)
|
|
13
|
+
@raw_payload = raw_payload
|
|
14
|
+
@raw_metadata = raw_metadata
|
|
15
|
+
@context = context
|
|
16
|
+
@parent_aggregates = parent_aggregates
|
|
17
|
+
@aggregate_tracker = aggregate_tracker
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Access payload values by key
|
|
21
|
+
#
|
|
22
|
+
# @param key [Symbol, String] The key to access
|
|
23
|
+
# @return [Object] The value for the given key
|
|
24
|
+
delegate :[], to: :@raw_payload
|
|
25
|
+
|
|
26
|
+
# Access metadata through a proxy object
|
|
27
|
+
#
|
|
28
|
+
# @return [MetadataProxy] The metadata proxy object
|
|
29
|
+
def metadata
|
|
30
|
+
@metadata ||= MetadataProxy.new(@raw_metadata)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :raw_payload, :raw_metadata, :context, :parent_aggregates, :aggregate_tracker
|
|
36
|
+
|
|
37
|
+
# Handles dynamic method calls to access payload values or resolve aggregates
|
|
38
|
+
#
|
|
39
|
+
# @param method_name [Symbol] The method being called
|
|
40
|
+
# @param args [Array] Method arguments (unused)
|
|
41
|
+
# @yield Optional block passed to the method (unused)
|
|
42
|
+
# @yieldreturn [void]
|
|
43
|
+
# @return [Object] The payload value or resolved aggregate
|
|
44
|
+
def method_missing(method_name, *args, &)
|
|
45
|
+
if raw_payload.key?(method_name)
|
|
46
|
+
raw_payload[method_name]
|
|
47
|
+
elsif raw_payload.key?(:"#{method_name}_id")
|
|
48
|
+
resolve_aggregate(method_name)
|
|
49
|
+
else
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Checks if method can be handled
|
|
55
|
+
#
|
|
56
|
+
# @param method_name [Symbol] The method to check
|
|
57
|
+
# @param include_private [Boolean] Whether to include private methods
|
|
58
|
+
# @return [Boolean] True if method can be handled
|
|
59
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
60
|
+
raw_payload.key?(method_name) ||
|
|
61
|
+
raw_payload.key?(:"#{method_name}_id") ||
|
|
62
|
+
super
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Resolves an aggregate instance from its ID in the payload
|
|
66
|
+
#
|
|
67
|
+
# @param method_name [Symbol] The method name representing the aggregate
|
|
68
|
+
# @return [Object] The resolved aggregate instance
|
|
69
|
+
def resolve_aggregate(method_name)
|
|
70
|
+
id = raw_payload[:"#{method_name}_id"]
|
|
71
|
+
context = aggregate_context(method_name)
|
|
72
|
+
aggregate_class = "#{context}::#{method_name.to_s.camelize}::Aggregate".constantize
|
|
73
|
+
instance = aggregate_class.new(id)
|
|
74
|
+
|
|
75
|
+
aggregate_tracker&.track(
|
|
76
|
+
attribute_name: method_name,
|
|
77
|
+
id: instance.id,
|
|
78
|
+
revision: -> { instance.reload.revision },
|
|
79
|
+
context:
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
instance
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def aggregate_context(aggregate_name)
|
|
86
|
+
parent_aggregates.with_indifferent_access.dig(aggregate_name, :context) || context
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Provides proxy access to command metadata
|
|
91
|
+
class MetadataProxy
|
|
92
|
+
# @param raw_metadata [Hash] The raw command metadata
|
|
93
|
+
def initialize(raw_metadata)
|
|
94
|
+
@raw_metadata = raw_metadata || {}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Access metadata values by key (hash-style access)
|
|
98
|
+
#
|
|
99
|
+
# @param key [Symbol, String] The key to access
|
|
100
|
+
# @return [Object] The value for the given key
|
|
101
|
+
delegate :[], to: :raw_metadata
|
|
102
|
+
|
|
103
|
+
# Set metadata values by key (hash-style assignment)
|
|
104
|
+
#
|
|
105
|
+
# @param key [Symbol, String] The key to set
|
|
106
|
+
# @param value [Object] The value to set
|
|
107
|
+
# @return [Object] The value that was set
|
|
108
|
+
delegate :[]=, to: :raw_metadata
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
attr_reader :raw_metadata
|
|
113
|
+
|
|
114
|
+
# Handles dynamic method calls to access or set metadata values
|
|
115
|
+
#
|
|
116
|
+
# @param method_name [Symbol] The method being called
|
|
117
|
+
# @param args [Array] Method arguments
|
|
118
|
+
# @yield Optional block passed to the method (unused)
|
|
119
|
+
# @yieldreturn [void]
|
|
120
|
+
# @return [Object] The metadata value or the value being set
|
|
121
|
+
def method_missing(method_name, *args, &)
|
|
122
|
+
method_str = method_name.to_s
|
|
123
|
+
|
|
124
|
+
# Handle setter methods (e.g., xyz=)
|
|
125
|
+
if method_str.end_with?('=')
|
|
126
|
+
key = method_str.chomp('=').to_sym
|
|
127
|
+
raw_metadata[key] = args.first
|
|
128
|
+
# Handle getter methods
|
|
129
|
+
elsif args.empty?
|
|
130
|
+
if raw_metadata.key?(method_name)
|
|
131
|
+
raw_metadata[method_name]
|
|
132
|
+
elsif raw_metadata.key?(method_str)
|
|
133
|
+
raw_metadata[method_str]
|
|
134
|
+
end
|
|
135
|
+
else
|
|
136
|
+
super
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Checks if method can be handled
|
|
141
|
+
#
|
|
142
|
+
# @param method_name [Symbol] The method to check
|
|
143
|
+
# @param include_private [Boolean] Whether to include private methods
|
|
144
|
+
# @return [Boolean] True if method can be handled
|
|
145
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
146
|
+
method_str = method_name.to_s
|
|
147
|
+
|
|
148
|
+
# Respond to setter methods
|
|
149
|
+
return true if method_str.end_with?('=')
|
|
150
|
+
|
|
151
|
+
# Respond to getter methods
|
|
152
|
+
raw_metadata.key?(method_name) ||
|
|
153
|
+
raw_metadata.key?(method_str) ||
|
|
154
|
+
super
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|