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,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Commands
|
|
6
|
+
class Bus
|
|
7
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
8
|
+
|
|
9
|
+
attr_reader :command_processor, :perform_inline
|
|
10
|
+
private :command_processor, :perform_inline
|
|
11
|
+
|
|
12
|
+
def initialize(
|
|
13
|
+
command_processor: Processor,
|
|
14
|
+
perform_inline: Yes::Core.configuration.process_commands_inline
|
|
15
|
+
)
|
|
16
|
+
@command_processor = command_processor
|
|
17
|
+
@perform_inline = perform_inline
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Passees commands on to the command processor, in case origin is not provided,
|
|
21
|
+
# it will be derived from the caller. Also decides based on config whether to perform commands inline or not
|
|
22
|
+
# @param command_or_commands [Command, Array<Command>] Command(s) instance(s)
|
|
23
|
+
# @param origin [String] Origin of the command
|
|
24
|
+
# @param notifier_options [Hash] Options for command notifier
|
|
25
|
+
# @param batch_id [String] Batch ID
|
|
26
|
+
# @return [void]
|
|
27
|
+
def call(
|
|
28
|
+
command_or_commands,
|
|
29
|
+
origin: nil,
|
|
30
|
+
notifier_options: {},
|
|
31
|
+
batch_id: nil
|
|
32
|
+
)
|
|
33
|
+
origin ||= Utils::CallerUtils.origin_from_caller(caller_locations(1..1).first)
|
|
34
|
+
|
|
35
|
+
perform_method = perform_inline ? :perform_now : :perform_later
|
|
36
|
+
self.class.current_span&.add_attributes({ perform_method: perform_method.to_s, origin: }.stringify_keys)
|
|
37
|
+
|
|
38
|
+
command_processor.public_send(
|
|
39
|
+
perform_method, origin, command_or_commands, notifier_options, batch_id, perform_inline
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
otl_trackable :call, Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Command Bus Schedule')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Commands
|
|
6
|
+
# Represents a group of commands executed in a transaction.
|
|
7
|
+
# Provides DSL for defining which commands belong to the group
|
|
8
|
+
# and handles payload normalization across contexts and subjects.
|
|
9
|
+
class Group
|
|
10
|
+
RESERVED_KEYS = Yes::Core::Command::RESERVED_KEYS
|
|
11
|
+
|
|
12
|
+
# Meta attributes for the Group, compatible with the Command class.
|
|
13
|
+
class Attributes < Dry::Struct
|
|
14
|
+
class Invalid < Error; end
|
|
15
|
+
|
|
16
|
+
attribute? :transaction, Types.Instance(TransactionDetails).optional
|
|
17
|
+
attribute? :origin, Types::String.optional
|
|
18
|
+
attribute? :batch_id, Types::String.optional
|
|
19
|
+
attribute? :metadata, Types::Hash.optional
|
|
20
|
+
attribute(:command_id, Types::UUID.default { SecureRandom.uuid })
|
|
21
|
+
|
|
22
|
+
# @param attributes [Hash] constructor parameters
|
|
23
|
+
# @raise [Invalid] if the parameters are invalid
|
|
24
|
+
def self.new(attributes)
|
|
25
|
+
super
|
|
26
|
+
rescue Dry::Struct::Error => e
|
|
27
|
+
raise Invalid.new(extra: attributes), e
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# @return [Array<Class>] List of command classes used in this group
|
|
33
|
+
attr_reader :commands
|
|
34
|
+
|
|
35
|
+
# @return [Array<Symbol>] List of unique command contexts
|
|
36
|
+
def command_contexts
|
|
37
|
+
commands.map { _1.to_s.split('::')[0].underscore.to_sym }.uniq
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Array<Symbol>] List of subjects in the current context
|
|
41
|
+
def own_context_subjects
|
|
42
|
+
commands.
|
|
43
|
+
select { _1.to_s.split('::')[0].underscore.to_sym == own_context }.
|
|
44
|
+
map { _1.to_s.split('::')[1].underscore.to_sym }.uniq
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Symbol] The context of the current command group
|
|
48
|
+
def own_context
|
|
49
|
+
to_s.split('::')[0].underscore.to_sym
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Symbol] The subject of the current command group
|
|
53
|
+
def own_subject
|
|
54
|
+
to_s.split('::')[1].underscore.to_sym
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Defines a command for the command group.
|
|
58
|
+
#
|
|
59
|
+
# @param command_name [String] the command class name, e.g. 'NameChanged'
|
|
60
|
+
# @param context [String] the context of the command, defaults to the first module
|
|
61
|
+
# @param subject [String] the subject of the command, defaults to the second module
|
|
62
|
+
# @return [void]
|
|
63
|
+
def command(command_name, context: to_s.split('::')[0], subject: to_s.split('::')[1])
|
|
64
|
+
@commands ||= []
|
|
65
|
+
@commands <<
|
|
66
|
+
Object.const_get(
|
|
67
|
+
"#{context}::#{subject}::Commands::#{command_name}::Command"
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Hash] The payload of the command group
|
|
73
|
+
attr_reader :payload
|
|
74
|
+
|
|
75
|
+
# @return [Attributes] The attributes of the command group
|
|
76
|
+
attr_reader :group_attributes
|
|
77
|
+
|
|
78
|
+
# @return [Array<Command>] Command instances of the group's commands
|
|
79
|
+
attr_reader :commands
|
|
80
|
+
|
|
81
|
+
delegate :transaction, :origin, :batch_id, :metadata, :command_id, to: :group_attributes
|
|
82
|
+
|
|
83
|
+
# Returns the aggregate ID from the first command in the group.
|
|
84
|
+
#
|
|
85
|
+
# @return [String, nil] the aggregate ID
|
|
86
|
+
def aggregate_id
|
|
87
|
+
commands.first&.aggregate_id
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Initialize a new Group.
|
|
91
|
+
#
|
|
92
|
+
# @param params [Hash] Parameters for the command group (meta attributes and command payload)
|
|
93
|
+
def initialize(params)
|
|
94
|
+
@group_attributes = Attributes.new(params.slice(*Yes::Core::Command::RESERVED_KEYS))
|
|
95
|
+
@payload = normalized_payloads(params)
|
|
96
|
+
@commands = self.class.commands.map do |command|
|
|
97
|
+
command.new(
|
|
98
|
+
payload.dig(command.to_s.split('::')[0].underscore.to_sym, command.to_s.split('::')[1].underscore.to_sym)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Returns the command group as a hash for serialization.
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] payloads and meta attributes merged
|
|
106
|
+
def to_h
|
|
107
|
+
transaction = group_attributes.transaction
|
|
108
|
+
merged = payload.merge(group_attributes.to_h)
|
|
109
|
+
transaction ? merged.merge(transaction:) : merged
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Normalizes the payloads for the command group.
|
|
115
|
+
#
|
|
116
|
+
# @param params [Hash] the input parameters
|
|
117
|
+
# @return [Hash] the normalized payloads
|
|
118
|
+
def normalized_payloads(params)
|
|
119
|
+
params.without(RESERVED_KEYS).each_with_object({}) do |(key, value), norm_payloads|
|
|
120
|
+
if key.in?(self.class.command_contexts)
|
|
121
|
+
norm_payloads[key] = value
|
|
122
|
+
elsif key.in?(self.class.own_context_subjects)
|
|
123
|
+
norm_payloads[self.class.own_context] ||= {}
|
|
124
|
+
norm_payloads[self.class.own_context][key] = value
|
|
125
|
+
else
|
|
126
|
+
norm_payloads[self.class.own_context] ||= {}
|
|
127
|
+
norm_payloads[self.class.own_context][self.class.own_subject] ||= {}
|
|
128
|
+
norm_payloads[self.class.own_context][self.class.own_subject][key] = value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Commands
|
|
6
|
+
class GroupResponse < Response
|
|
7
|
+
attribute :cmd, Yes::Core::Types.Instance(Yes::Core::Commands::Group)
|
|
8
|
+
attribute? :error,
|
|
9
|
+
Yes::Core::Types.Instance(Yes::Core::Commands::Stateless::GroupHandler::CommandsError).optional
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Commands
|
|
6
|
+
# Provides naming resolution helpers for commands following the V2 folder structure
|
|
7
|
+
# (e.g. Context::Subject::Commands::DoSomething::Command).
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# helper = Yes::Core::Commands::Helper.new(command)
|
|
11
|
+
# helper.command_name
|
|
12
|
+
class Helper
|
|
13
|
+
AGGREGATE_CLASSNAME = 'Aggregate'
|
|
14
|
+
VERSION_REGEXP = /::(?<version>V\d+)::/
|
|
15
|
+
|
|
16
|
+
attr_reader :inflector, :cmd
|
|
17
|
+
private :inflector, :cmd
|
|
18
|
+
|
|
19
|
+
delegate :splitted_command, to: :class
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Splits the command class name into module parts.
|
|
23
|
+
#
|
|
24
|
+
# @param cmd [Yes::Core::Command] the command instance
|
|
25
|
+
# @return [Array<String>] the split class name parts
|
|
26
|
+
def splitted_command(cmd)
|
|
27
|
+
cmd.class.to_s.split('::')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param cmd [Yes::Core::Command] the command instance
|
|
32
|
+
def initialize(cmd)
|
|
33
|
+
@inflector = Dry::Inflector.new
|
|
34
|
+
@cmd = cmd
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns the top-level context module of the command.
|
|
38
|
+
#
|
|
39
|
+
# @return [String] the command context
|
|
40
|
+
def command_context
|
|
41
|
+
splitted_command(cmd).first
|
|
42
|
+
end
|
|
43
|
+
alias context command_context
|
|
44
|
+
|
|
45
|
+
# Returns the locale for the command.
|
|
46
|
+
#
|
|
47
|
+
# @return [Symbol] the locale
|
|
48
|
+
def command_locale
|
|
49
|
+
cmd.respond_to?(:locale) ? cmd.locale : I18n.locale
|
|
50
|
+
end
|
|
51
|
+
alias locale command_locale
|
|
52
|
+
|
|
53
|
+
# Extracts the version from the command class name.
|
|
54
|
+
#
|
|
55
|
+
# @return [String, nil] the version string (e.g. "V1") or nil
|
|
56
|
+
def command_version
|
|
57
|
+
VERSION_REGEXP.match(cmd.class.to_s)&.[](:version)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the event payload with stringified keys.
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash] the deep stringified event payload
|
|
63
|
+
def event_payload
|
|
64
|
+
cmd.payload.deep_stringify_keys
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns the underscored command name.
|
|
68
|
+
#
|
|
69
|
+
# @return [String] the command name
|
|
70
|
+
def command_name
|
|
71
|
+
inflector.underscore(splitted_command(cmd)[-2])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns the aggregate class name.
|
|
75
|
+
#
|
|
76
|
+
# @return [String] the aggregate class name
|
|
77
|
+
def aggregate_classname
|
|
78
|
+
AGGREGATE_CLASSNAME
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns the aggregate module name.
|
|
82
|
+
#
|
|
83
|
+
# @return [String] the aggregate module name
|
|
84
|
+
def aggregate_module
|
|
85
|
+
splitted_command(cmd)[-4]
|
|
86
|
+
end
|
|
87
|
+
alias subject aggregate_module
|
|
88
|
+
|
|
89
|
+
# Returns the fully qualified authorizer class name.
|
|
90
|
+
#
|
|
91
|
+
# @return [String] the authorizer class name
|
|
92
|
+
def authorizer_classname
|
|
93
|
+
spl = splitted_command(cmd)
|
|
94
|
+
spl[0] == 'CommandGroups' ? "#{spl[0..1].join('::')}::Authorizer" : "#{spl[0..3].join('::')}::Authorizer"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns the fully qualified validator class name.
|
|
98
|
+
#
|
|
99
|
+
# @return [String] the validator class name
|
|
100
|
+
def validator_classname
|
|
101
|
+
spl = splitted_command(cmd)
|
|
102
|
+
spl[0] == 'CommandGroups' ? "#{spl[0..1].join('::')}::Validator" : "#{spl[0..3].join('::')}::Validator"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns the aggregate class constant.
|
|
106
|
+
#
|
|
107
|
+
# @return [Class] the aggregate class
|
|
108
|
+
def aggregate_class
|
|
109
|
+
inflector.constantize(
|
|
110
|
+
[
|
|
111
|
+
command_context,
|
|
112
|
+
command_version,
|
|
113
|
+
aggregate_module,
|
|
114
|
+
aggregate_classname
|
|
115
|
+
].compact.join('::')
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Returns the aggregate ID from the command.
|
|
120
|
+
#
|
|
121
|
+
# @return [String] the aggregate ID
|
|
122
|
+
delegate :aggregate_id, to: :cmd
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# @abstract Command notifier base class. Subclass and override notification methods to implement
|
|
4
|
+
# a custom notifier.
|
|
5
|
+
module Yes
|
|
6
|
+
module Core
|
|
7
|
+
module Commands
|
|
8
|
+
class Notifier
|
|
9
|
+
attr_reader :channel
|
|
10
|
+
private :channel
|
|
11
|
+
|
|
12
|
+
# @param options [Hash] notifier options
|
|
13
|
+
# @option options [String] :channel the notification channel
|
|
14
|
+
def initialize(options = {})
|
|
15
|
+
@channel = options[:channel]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Implement this method to notify that a batch has started processing
|
|
19
|
+
# @param batch_id [String] batch id of the batch that has started processing
|
|
20
|
+
# @param transaction [TransactionDetails] the transaction details of the current transaction
|
|
21
|
+
# @param commands [Array<Command>] the commands that are being processed
|
|
22
|
+
def notify_batch_started(batch_id, transaction = nil, commands = nil)
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Implement this method to notify that a batch has finished processing
|
|
27
|
+
# @param batch_id [String] batch id of the batch that has finished processing
|
|
28
|
+
# @param transaction [TransactionDetails] the transaction details of the current transaction
|
|
29
|
+
# @param responses [Array<Response>] the responses of the commands that were processed
|
|
30
|
+
def notify_batch_finished(batch_id, transaction = nil, responses = nil)
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Implement this method to notify that a command response has been received
|
|
35
|
+
# @param cmd_response [Yes::Core::Commands::Response] the command response to notify
|
|
36
|
+
def notify_command_response(cmd_response)
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Wraps the given block in a batch notification.
|
|
41
|
+
# @param batch_id [String] the batch id
|
|
42
|
+
# @param commands [Array<Command>] the commands being processed in the batch
|
|
43
|
+
# @param transaction [TransactionDetails] the transaction details of the current transaction
|
|
44
|
+
# @yield executes commands within the batch notification
|
|
45
|
+
# @yieldreturn [Array<Response>] responses from the executed commands
|
|
46
|
+
# @return [Array<Response>] responses from the executed commands
|
|
47
|
+
def with_batch_notification(batch_id, commands, transaction = nil)
|
|
48
|
+
notify_batch_started(batch_id, transaction, commands)
|
|
49
|
+
response = yield
|
|
50
|
+
notify_batch_finished(batch_id, transaction, response)
|
|
51
|
+
|
|
52
|
+
response
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.with_batch_notification(notifiers, batch_id, commands, transaction = nil)
|
|
56
|
+
notifiers.each { _1.notify_batch_started(batch_id, transaction, commands) }
|
|
57
|
+
response = yield
|
|
58
|
+
notifiers.each { _1.notify_batch_finished(batch_id, transaction, response) }
|
|
59
|
+
|
|
60
|
+
response
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Commands
|
|
6
|
+
# Processes commands asynchronously through ActiveJob
|
|
7
|
+
# @since 0.1.0
|
|
8
|
+
class Processor < ActiveJob::Base
|
|
9
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
10
|
+
|
|
11
|
+
queue_as :commands
|
|
12
|
+
|
|
13
|
+
# Error raised when a command is not registered with a handler
|
|
14
|
+
UnregisteredCommand = Class.new(Error)
|
|
15
|
+
|
|
16
|
+
# @return [Object] The notifier for command processing events
|
|
17
|
+
attr_reader :command_notifiers, :custom_batch_id
|
|
18
|
+
private :command_notifiers, :custom_batch_id
|
|
19
|
+
|
|
20
|
+
# Processes the given commands by running them through their respective handlers.
|
|
21
|
+
# @param origin [String] a string identifying the origin of the commands
|
|
22
|
+
# @param command_or_commands [Command, Array<Command>] the command or commands to process
|
|
23
|
+
# @param notifier_options [Hash] options to pass to the command notifier
|
|
24
|
+
# @param custom_batch_id [String] Custom batch ID
|
|
25
|
+
# @return [Array<Response>] the responses from the performed commands
|
|
26
|
+
# @raise [UnregisteredCommand] if any command lacks a handler
|
|
27
|
+
def perform(origin, command_or_commands, notifier_options, custom_batch_id = nil, inline = false)
|
|
28
|
+
setup(notifier_options, custom_batch_id, inline)
|
|
29
|
+
singleton_class.current_span&.add_event('Command Processor Setup Done')
|
|
30
|
+
|
|
31
|
+
commands = [*command_or_commands]
|
|
32
|
+
ensure_guard_evaluators_exist?(commands)
|
|
33
|
+
singleton_class.current_span&.add_event('Ensured Guard Evaluators Exist')
|
|
34
|
+
|
|
35
|
+
commands.map! { |cmd| cmd.class.new(cmd.to_h.merge(origin:, batch_id:)) }
|
|
36
|
+
singleton_class.current_span&.add_event('Commands Mapped')
|
|
37
|
+
|
|
38
|
+
if command_notifiers.any?
|
|
39
|
+
singleton_class.with_otl_span 'Run Commands With Notifiers' do
|
|
40
|
+
Notifier.with_batch_notification(command_notifiers, batch_id, commands) do
|
|
41
|
+
singleton_class.with_otl_span 'Run Commands' do
|
|
42
|
+
run_commands(commands)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
else
|
|
47
|
+
singleton_class.with_otl_span 'Run Commands' do
|
|
48
|
+
run_commands(commands)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
otl_trackable :perform, Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Command Processor Perform')
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Instantiates the command notifier from the config, using the given options.
|
|
57
|
+
# @param notifier_options [Hash] the options to pass to the command notifier
|
|
58
|
+
# @param custom_batch_id [String] Custom batch ID
|
|
59
|
+
# @param inline [Boolean] whether to process the commands inline
|
|
60
|
+
# @return [void]
|
|
61
|
+
def setup(notifier_options, custom_batch_id, inline = false)
|
|
62
|
+
@command_notifiers = [] if inline
|
|
63
|
+
|
|
64
|
+
@command_notifiers ||= Yes::Core.configuration.command_notifier_classes&.map do |notifier_class|
|
|
65
|
+
notifier_class.new(notifier_options)
|
|
66
|
+
end || []
|
|
67
|
+
@custom_batch_id = custom_batch_id
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Runs the given commands through their respective aggregates.
|
|
71
|
+
# @param commands [Array<Command>] the commands to run
|
|
72
|
+
# @return [Array<Response>] responses from the performed commands
|
|
73
|
+
def run_commands(commands, _inline = false)
|
|
74
|
+
commands.map do |cmd|
|
|
75
|
+
cmd_response = run_command(cmd)
|
|
76
|
+
command_notifiers.each { _1.notify_command_response(cmd_response) }
|
|
77
|
+
cmd_response
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Executes a single command on its aggregate
|
|
82
|
+
# @param cmd [Command] the command to execute
|
|
83
|
+
# @return [Response] response from executing the command
|
|
84
|
+
def run_command(cmd)
|
|
85
|
+
command_helper = Yes::Core::Commands::Helper.new(cmd)
|
|
86
|
+
draft = draft?(cmd)
|
|
87
|
+
aggregate = aggregate_class(cmd).new(cmd.aggregate_id, draft:)
|
|
88
|
+
I18n.with_locale(command_helper.command_locale) do
|
|
89
|
+
# Pass payload as first argument, guards as option
|
|
90
|
+
aggregate.public_send(command_helper.command_name, cmd.to_h, guards: !draft)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def draft?(cmd)
|
|
95
|
+
cmd.metadata&.dig(:draft) || cmd.metadata&.dig(:edit_template_command)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Determines the aggregate class for a given command
|
|
99
|
+
# @param cmd [Command] The command to find the aggregate class for
|
|
100
|
+
# @return [Class] The aggregate class that handles this command
|
|
101
|
+
def aggregate_class(cmd)
|
|
102
|
+
Yes::Core::Commands::Helper.new(cmd).aggregate_class
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Checks if a guard evaluator exists for the given command
|
|
106
|
+
# @param cmd [Command] The command to check
|
|
107
|
+
# @return [Boolean] true if a guard evaluator exists
|
|
108
|
+
# @raise [UnregisteredCommand] if no guard evaluator is found for the command
|
|
109
|
+
def guard_evaluator_exists?(cmd)
|
|
110
|
+
command_helper = Yes::Core::Commands::Helper.new(cmd)
|
|
111
|
+
|
|
112
|
+
klass = Yes::Core.configuration.guard_evaluator_class(command_helper.command_context,
|
|
113
|
+
command_helper.subject,
|
|
114
|
+
command_helper.command_name)
|
|
115
|
+
|
|
116
|
+
raise UnregisteredCommand, "Unregistered command: #{cmd.class}" unless klass
|
|
117
|
+
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Ensures handlers exist for all commands
|
|
122
|
+
# @param commands [Array<Command>] The commands to check
|
|
123
|
+
# @return [Boolean] true if handlers exist for all commands
|
|
124
|
+
# @raise [UnregisteredCommand] if any command lacks a handler
|
|
125
|
+
def ensure_guard_evaluators_exist?(commands)
|
|
126
|
+
commands.all? { guard_evaluator_exists?(_1) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Returns the batch id of the current batch.
|
|
130
|
+
# @return [String] the batch id
|
|
131
|
+
def batch_id
|
|
132
|
+
custom_batch_id || job_id
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Commands
|
|
6
|
+
class Response < Dry::Struct
|
|
7
|
+
attribute :cmd, Yes::Core::Types.Instance(Command)
|
|
8
|
+
attribute? :event, Yes::Core::Types.Instance(PgEventstore::Event).optional
|
|
9
|
+
attribute? :error,
|
|
10
|
+
Yes::Core::Types.Instance(Yes::Core::CommandHandling::GuardEvaluator::TransitionError).
|
|
11
|
+
optional
|
|
12
|
+
|
|
13
|
+
# @return [TransactionDetails, nil] command's transaction info if present
|
|
14
|
+
#
|
|
15
|
+
delegate :transaction, :batch_id, :payload, :metadata, to: :cmd
|
|
16
|
+
|
|
17
|
+
# @return [Boolean] true in case the command was processed successfully
|
|
18
|
+
#
|
|
19
|
+
def success?
|
|
20
|
+
error.blank?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Hash] error details in case an error occurred
|
|
24
|
+
#
|
|
25
|
+
def error_details
|
|
26
|
+
return {} unless error
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
message: error.message,
|
|
30
|
+
type: error.message&.underscore&.tr(' ', '_'),
|
|
31
|
+
extra: (error.extra if error.respond_to?(:extra) && error.extra.present?)
|
|
32
|
+
}.compact
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [String] type of the command response
|
|
36
|
+
#
|
|
37
|
+
def type
|
|
38
|
+
success? ? 'command_success' : 'command_error'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash] command response as a hash
|
|
42
|
+
#
|
|
43
|
+
def to_notification
|
|
44
|
+
error = success? ? {} : { error_details: }
|
|
45
|
+
{
|
|
46
|
+
type:,
|
|
47
|
+
batch_id:,
|
|
48
|
+
payload:,
|
|
49
|
+
metadata:,
|
|
50
|
+
command: cmd.class.name,
|
|
51
|
+
id: cmd.command_id,
|
|
52
|
+
transaction: transaction.to_h
|
|
53
|
+
}.merge(error)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Hash]
|
|
57
|
+
def as_json(*)
|
|
58
|
+
to_notification.as_json
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|