yes-core 1.2.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +13 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +7 -4
- data/lib/yes/core/aggregate/dsl/class_name_convention.rb +8 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/base.rb +34 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/command.rb +43 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/guard_evaluator.rb +35 -0
- data/lib/yes/core/aggregate/dsl/command_group_data.rb +45 -0
- data/lib/yes/core/aggregate/dsl/command_group_definer.rb +100 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command_group/base.rb +29 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command_group/can_command_group.rb +41 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command_group/command_group.rb +40 -0
- data/lib/yes/core/aggregate.rb +50 -0
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +20 -5
- data/lib/yes/core/authorization/lookup_cache.rb +72 -0
- data/lib/yes/core/command_handling/command_group_executor.rb +237 -0
- data/lib/yes/core/command_handling/command_group_handler.rb +89 -0
- data/lib/yes/core/command_handling/event_publisher.rb +8 -3
- data/lib/yes/core/commands/command_group.rb +152 -0
- data/lib/yes/core/commands/command_group_response.rb +66 -0
- data/lib/yes/core/commands/group.rb +7 -12
- data/lib/yes/core/commands/group_payload_normalizer.rb +45 -0
- data/lib/yes/core/commands/processor.rb +28 -7
- data/lib/yes/core/commands/stateless/handler.rb +9 -3
- data/lib/yes/core/commands/stateless/handler_helpers.rb +1 -1
- data/lib/yes/core/configuration.rb +39 -0
- data/lib/yes/core/data_decryptor.rb +2 -2
- data/lib/yes/core/data_encryptor.rb +6 -2
- data/lib/yes/core/failed_subscription_notifier.rb +29 -0
- data/lib/yes/core/middlewares/encryptor.rb +9 -1
- data/lib/yes/core/middlewares/write_encryptor.rb +35 -0
- data/lib/yes/core/middlewares.rb +45 -0
- data/lib/yes/core/railtie.rb +20 -0
- data/lib/yes/core/test_support/aggregate/command_test_dsl.rb +77 -2
- data/lib/yes/core/test_support/aggregate/shared_examples.rb +45 -0
- data/lib/yes/core/test_support/event_helpers.rb +1 -1
- data/lib/yes/core/types.rb +8 -1
- data/lib/yes/core/utils/command_utils.rb +21 -0
- data/lib/yes/core/utils/hash_utils.rb +7 -2
- data/lib/yes/core/version.rb +1 -1
- metadata +19 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4845d0466658aee907edb5bab0679603e25a9b5c088a8df067d0fbfcc060bff4
|
|
4
|
+
data.tar.gz: cca505e973fa99d5b8e4b07c44752b78b330fdc0035b45528c83139ee36a0e4b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08121024f7bb36c1bb17281d772c907f4c1e9c119437caf6642c0461fcc9ce2d434c15fa6dc548ec8c6362b520b5144f8869d7054c8e9a110e33a984d757fd63'
|
|
7
|
+
data.tar.gz: b2b2bcbf061918d7876e27c6014d41adfe1ed5133163a078d09a31490219105a57f47699c5bd2d6c9d3400c12df527c13e2d0f76d64b32f1d4a2b1aaec1b4b8f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.2.0] - 2026-09-01
|
|
4
|
+
|
|
5
|
+
- See root CHANGELOG.md for details.
|
|
6
|
+
|
|
7
|
+
## [1.4.0] - 2026-06-24
|
|
8
|
+
|
|
9
|
+
- See root CHANGELOG.md for details.
|
|
10
|
+
|
|
11
|
+
## [1.3.1] - 2026-06-16
|
|
12
|
+
|
|
13
|
+
- See root CHANGELOG.md for details.
|
|
14
|
+
|
|
15
|
+
## [1.3.0] - 2026-05-18
|
|
16
|
+
|
|
17
|
+
- See root CHANGELOG.md for details.
|
|
18
|
+
|
|
3
19
|
## [1.2.0] - 2026-04-30
|
|
4
20
|
|
|
5
21
|
- See root CHANGELOG.md for details.
|
data/README.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
Core event sourcing framework providing the aggregate DSL, commands, events, read models, and supporting infrastructure for the [Yes](https://github.com/yousty/yes) framework.
|
|
4
4
|
|
|
5
|
+
## Compatibility with pg_eventstore
|
|
6
|
+
|
|
7
|
+
⚠️ **pg_eventstore 3.x requires yes-core >= 2.1.1.** pg_eventstore 3.0.0 generates event
|
|
8
|
+
ids with `SecureRandom.uuid_v7` (it moved the default off the database's
|
|
9
|
+
`gen_random_uuid()`, which always produced v4). Those ids arrive as `causation_id` /
|
|
10
|
+
`correlation_id`, and `Types::UUID` accepted only v4 before 2.1.1 — so
|
|
11
|
+
`TransactionDetails.new` raised `Dry::Struct::Error`, the event handler failed, and the
|
|
12
|
+
subscription died once its restarts ran out.
|
|
13
|
+
|
|
14
|
+
`Types::UUID` now accepts any RFC 9562 version (1-8) while still requiring a valid
|
|
15
|
+
version and variant nibble.
|
|
16
|
+
|
|
17
|
+
|
|
5
18
|
## Installation
|
|
6
19
|
|
|
7
20
|
Add this line to your application's Gemfile:
|
|
@@ -3,22 +3,25 @@
|
|
|
3
3
|
module Yes
|
|
4
4
|
module Core
|
|
5
5
|
module ActiveJobSerializers
|
|
6
|
-
# ActiveJob serializer for
|
|
6
|
+
# ActiveJob serializer for {Yes::Core::Commands::Group} (legacy stateless
|
|
7
|
+
# cross-aggregate groups) and {Yes::Core::Commands::CommandGroup}
|
|
8
|
+
# (aggregate-DSL groups). Both round-trip through `to_h` / `Class.new`.
|
|
7
9
|
class CommandGroupSerializer < ActiveJob::Serializers::ObjectSerializer
|
|
8
10
|
# @param argument [Object] the argument to check
|
|
9
11
|
# @return [Boolean] true if the argument can be serialized
|
|
10
12
|
def serialize?(argument)
|
|
11
|
-
argument.is_a?
|
|
13
|
+
argument.is_a?(Yes::Core::Commands::Group) ||
|
|
14
|
+
argument.is_a?(Yes::Core::Commands::CommandGroup)
|
|
12
15
|
end
|
|
13
16
|
|
|
14
|
-
# @param command_group [Yes::Core::Commands::Group]
|
|
17
|
+
# @param command_group [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
|
|
15
18
|
# @return [Hash] the serialized representation
|
|
16
19
|
def serialize(command_group)
|
|
17
20
|
super(command_group.to_h.merge(_type: command_group.class.name))
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
# @param hash [Hash] the serialized representation
|
|
21
|
-
# @return [Yes::Core::Commands::Group]
|
|
24
|
+
# @return [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
|
|
22
25
|
def deserialize(hash)
|
|
23
26
|
symbolized_hash = hash.deep_symbolize_keys
|
|
24
27
|
Object.const_get(symbolized_hash[:_type]).new(symbolized_hash.except(:_aj_serialized, :_type))
|
|
@@ -43,6 +43,14 @@ module Yes
|
|
|
43
43
|
"#{context}::#{aggregate}::Commands::#{name.to_s.camelize}::GuardEvaluator"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
def command_group_class_name(name)
|
|
47
|
+
"#{context}::#{aggregate}::CommandGroups::#{name.to_s.camelize}::Command"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def command_group_guard_evaluator_class_name(name)
|
|
51
|
+
"#{context}::#{aggregate}::CommandGroups::#{name.to_s.camelize}::GuardEvaluator"
|
|
52
|
+
end
|
|
53
|
+
|
|
46
54
|
def state_updater_class_name(name)
|
|
47
55
|
"#{context}::#{aggregate}::Commands::#{name.to_s.camelize}::StateUpdater"
|
|
48
56
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
class Aggregate
|
|
6
|
+
module Dsl
|
|
7
|
+
module ClassResolvers
|
|
8
|
+
module CommandGroup
|
|
9
|
+
# Base class for command_group-related class resolvers.
|
|
10
|
+
#
|
|
11
|
+
# Mirrors {ClassResolvers::Command::Base} but binds to
|
|
12
|
+
# {CommandGroupData} instead of {CommandData}.
|
|
13
|
+
#
|
|
14
|
+
# @abstract Subclass and implement {ClassResolvers::Base#class_type},
|
|
15
|
+
# {ClassResolvers::Base#class_name}, and
|
|
16
|
+
# {ClassResolvers::Base#generate_class}.
|
|
17
|
+
class Base < ClassResolvers::Base
|
|
18
|
+
# @param command_group_data [Yes::Core::Aggregate::Dsl::CommandGroupData]
|
|
19
|
+
def initialize(command_group_data)
|
|
20
|
+
@command_group_data = command_group_data
|
|
21
|
+
|
|
22
|
+
super(command_group_data.context_name, command_group_data.aggregate_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :command_group_data
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
class Aggregate
|
|
6
|
+
module Dsl
|
|
7
|
+
module ClassResolvers
|
|
8
|
+
module CommandGroup
|
|
9
|
+
# Resolves or generates the Command class for an aggregate-DSL
|
|
10
|
+
# command group. The generated class is a {Yes::Core::Commands::CommandGroup}
|
|
11
|
+
# subclass carrying the group's identity (context, aggregate,
|
|
12
|
+
# group_name) and the ordered list of sub-command names.
|
|
13
|
+
class Command < Base
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def class_type
|
|
17
|
+
:command_group
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def class_name
|
|
21
|
+
command_group_data.name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def generate_class
|
|
25
|
+
group_name = command_group_data.name
|
|
26
|
+
ctx = command_group_data.context_name
|
|
27
|
+
agg = command_group_data.aggregate_name
|
|
28
|
+
sub_commands = command_group_data.sub_command_names.dup
|
|
29
|
+
|
|
30
|
+
Class.new(Yes::Core::Commands::CommandGroup).tap do |klass|
|
|
31
|
+
klass.context = ctx
|
|
32
|
+
klass.aggregate = agg
|
|
33
|
+
klass.group_name = group_name
|
|
34
|
+
klass.sub_command_names = sub_commands
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
class Aggregate
|
|
6
|
+
module Dsl
|
|
7
|
+
module ClassResolvers
|
|
8
|
+
module CommandGroup
|
|
9
|
+
# Resolves or generates the GuardEvaluator class for a command_group.
|
|
10
|
+
#
|
|
11
|
+
# Unlike the per-command resolver, no `:no_change` guard is
|
|
12
|
+
# auto-injected — command groups are intended to be lighter on
|
|
13
|
+
# guard checks and rely on whatever set of guards the user
|
|
14
|
+
# declares explicitly in the DSL block.
|
|
15
|
+
class GuardEvaluator < Base
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def class_type
|
|
19
|
+
:command_group_guard_evaluator
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def class_name
|
|
23
|
+
command_group_data.name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def generate_class
|
|
27
|
+
Class.new(Yes::Core::CommandHandling::GuardEvaluator)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
class Aggregate
|
|
6
|
+
module Dsl
|
|
7
|
+
# Data object that holds information about a command_group definition
|
|
8
|
+
# in an aggregate.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# CommandGroupData.new(:create_apprenticeship, MyAggregate, context: 'Companies', aggregate: 'Apprenticeship')
|
|
12
|
+
class CommandGroupData
|
|
13
|
+
attr_reader :name, :context_name, :aggregate_name, :aggregate_class
|
|
14
|
+
attr_accessor :sub_command_names, :guard_names
|
|
15
|
+
|
|
16
|
+
# @param name [Symbol] the name of the command group
|
|
17
|
+
# @param aggregate_class [Class] the aggregate class the group belongs to
|
|
18
|
+
# @param options [Hash] additional options
|
|
19
|
+
# @option options [String] :context the context name
|
|
20
|
+
# @option options [String] :aggregate the aggregate name
|
|
21
|
+
def initialize(name, aggregate_class, options = {})
|
|
22
|
+
@name = name
|
|
23
|
+
@aggregate_class = aggregate_class
|
|
24
|
+
@context_name = options[:context]
|
|
25
|
+
@aggregate_name = options[:aggregate]
|
|
26
|
+
@sub_command_names = []
|
|
27
|
+
@guard_names = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param name [Symbol] sub-command name to append (preserves order)
|
|
31
|
+
# @return [void]
|
|
32
|
+
def add_sub_command(name)
|
|
33
|
+
@sub_command_names << name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param name [Symbol] guard name to record on this group
|
|
37
|
+
# @return [void]
|
|
38
|
+
def add_guard(name)
|
|
39
|
+
@guard_names << name
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
class Aggregate
|
|
6
|
+
module Dsl
|
|
7
|
+
# Factory class that creates and defines command_groups on aggregates.
|
|
8
|
+
#
|
|
9
|
+
# Mirrors {CommandDefiner}. The DSL evaluator inside accepts two
|
|
10
|
+
# methods: `command :sub_command_name` to push a sub-command symbol,
|
|
11
|
+
# and `guard(name, error_extra: …) { … }` to register a group-level
|
|
12
|
+
# guard on the generated GuardEvaluator class.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# group_data = CommandGroupData.new(:create_apprenticeship, MyAggregate,
|
|
16
|
+
# context: 'Companies', aggregate: 'Apprenticeship')
|
|
17
|
+
# CommandGroupDefiner.new(group_data).call do
|
|
18
|
+
# command :assign_company
|
|
19
|
+
# command :change_name
|
|
20
|
+
#
|
|
21
|
+
# guard(:company_assigned) { company_id.present? }
|
|
22
|
+
# end
|
|
23
|
+
class CommandGroupDefiner
|
|
24
|
+
# Raised when an unknown sub-command name is referenced.
|
|
25
|
+
class UnknownSubCommandError < Yes::Core::Error; end
|
|
26
|
+
|
|
27
|
+
attr_reader :command_group_data
|
|
28
|
+
private :command_group_data
|
|
29
|
+
|
|
30
|
+
# @param command_group_data [CommandGroupData]
|
|
31
|
+
def initialize(command_group_data)
|
|
32
|
+
@command_group_data = command_group_data
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Generates and registers all classes/methods for the command group.
|
|
36
|
+
#
|
|
37
|
+
# @yield Block for declaring sub-commands and guards
|
|
38
|
+
# @return [void]
|
|
39
|
+
def call(&block)
|
|
40
|
+
create_and_register_guard_evaluator
|
|
41
|
+
evaluate_dsl_block(&block) if block
|
|
42
|
+
create_and_register_command
|
|
43
|
+
define_aggregate_methods
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def create_and_register_guard_evaluator
|
|
49
|
+
@guard_evaluator_class = ClassResolvers::CommandGroup::GuardEvaluator.new(command_group_data).call
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create_and_register_command
|
|
53
|
+
ClassResolvers::CommandGroup::Command.new(command_group_data).call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def define_aggregate_methods
|
|
57
|
+
MethodDefiners::CommandGroup::CommandGroup.new(command_group_data).call
|
|
58
|
+
MethodDefiners::CommandGroup::CanCommandGroup.new(command_group_data).call
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def evaluate_dsl_block(&)
|
|
62
|
+
DslEvaluator.new(command_group_data, @guard_evaluator_class).instance_eval(&)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# DSL evaluator that backs the `command_group :name do … end` block.
|
|
66
|
+
class DslEvaluator
|
|
67
|
+
attr_reader :command_group_data, :guard_evaluator_class
|
|
68
|
+
|
|
69
|
+
def initialize(command_group_data, guard_evaluator_class)
|
|
70
|
+
@command_group_data = command_group_data
|
|
71
|
+
@guard_evaluator_class = guard_evaluator_class
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Declare a sub-command. Order is preserved as execution order.
|
|
75
|
+
#
|
|
76
|
+
# @param name [Symbol] the sub-command name (must match a command
|
|
77
|
+
# declared on the same aggregate)
|
|
78
|
+
# @return [void]
|
|
79
|
+
def command(name)
|
|
80
|
+
command_group_data.add_sub_command(name.to_sym)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Register a group-level guard. Semantics match the per-command
|
|
84
|
+
# `guard` DSL — `:no_change` is recognized as the magic name that
|
|
85
|
+
# raises {NoChangeTransition} on failure.
|
|
86
|
+
#
|
|
87
|
+
# @param name [Symbol] the guard name
|
|
88
|
+
# @param error_extra [Hash, Proc] extra error context
|
|
89
|
+
# @yield Block returning true if the guard passes
|
|
90
|
+
# @return [void]
|
|
91
|
+
def guard(name, error_extra: {}, &)
|
|
92
|
+
command_group_data.add_guard(name)
|
|
93
|
+
guard_evaluator_class.guard(name, error_extra:, &)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
class Aggregate
|
|
6
|
+
module Dsl
|
|
7
|
+
module MethodDefiners
|
|
8
|
+
module CommandGroup
|
|
9
|
+
# Base class for command_group method definers.
|
|
10
|
+
class Base
|
|
11
|
+
def initialize(command_group_data)
|
|
12
|
+
@name = command_group_data.name
|
|
13
|
+
@aggregate_class = command_group_data.aggregate_class
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :name, :aggregate_class
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
class Aggregate
|
|
6
|
+
module Dsl
|
|
7
|
+
module MethodDefiners
|
|
8
|
+
module CommandGroup
|
|
9
|
+
# Defines `aggregate.can_<group_name>?(payload = {})` and the
|
|
10
|
+
# `<group_name>_error` accessor on the aggregate class.
|
|
11
|
+
#
|
|
12
|
+
# Mirrors {MethodDefiners::Command::CanCommand} but resolves the
|
|
13
|
+
# group's GuardEvaluator class instead of a command's.
|
|
14
|
+
class CanCommandGroup < Base
|
|
15
|
+
def call
|
|
16
|
+
can_method = :"can_#{@name}?"
|
|
17
|
+
error_method = :"#{@name}_error"
|
|
18
|
+
|
|
19
|
+
aggregate_class.attr_accessor error_method
|
|
20
|
+
group_name = @name
|
|
21
|
+
|
|
22
|
+
aggregate_class.define_method(can_method) do |payload = {}|
|
|
23
|
+
cmd = command_utilities.build_group_command(group_name, payload)
|
|
24
|
+
guard_evaluator_class = command_utilities.fetch_guard_evaluator_class_for_group(group_name)
|
|
25
|
+
|
|
26
|
+
Yes::Core::CommandHandling::GuardRunner.new(self).call(
|
|
27
|
+
cmd, group_name, guard_evaluator_class, skip_guards: false
|
|
28
|
+
).present?
|
|
29
|
+
rescue Yes::Core::CommandHandling::GuardEvaluator::InvalidTransition,
|
|
30
|
+
Yes::Core::CommandHandling::GuardEvaluator::NoChangeTransition,
|
|
31
|
+
Yes::Core::Command::Invalid
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
class Aggregate
|
|
6
|
+
module Dsl
|
|
7
|
+
module MethodDefiners
|
|
8
|
+
module CommandGroup
|
|
9
|
+
# Defines `aggregate.<group_name>(payload = nil, **options)` on the
|
|
10
|
+
# aggregate class. Mirrors {MethodDefiners::Command::Command} but
|
|
11
|
+
# delegates to {Yes::Core::CommandHandling::CommandGroupHandler}.
|
|
12
|
+
class CommandGroup < Base
|
|
13
|
+
def call
|
|
14
|
+
group_name = @name
|
|
15
|
+
|
|
16
|
+
aggregate_class.define_method(group_name) do |payload = nil, **options|
|
|
17
|
+
payload = payload.clone if payload.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
guards = options.delete(:guards)
|
|
20
|
+
guards = true if guards.nil?
|
|
21
|
+
metadata = options.delete(:metadata)
|
|
22
|
+
|
|
23
|
+
if payload.nil? && !options.empty?
|
|
24
|
+
payload = options
|
|
25
|
+
elsif payload.nil?
|
|
26
|
+
payload = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Yes::Core::CommandHandling::CommandGroupHandler.new(self).call(
|
|
30
|
+
group_name, payload, guards:, metadata:
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/yes/core/aggregate.rb
CHANGED
|
@@ -66,6 +66,7 @@ module Yes
|
|
|
66
66
|
if tp.self == subclass
|
|
67
67
|
subclass.setup_read_model_classes if subclass.read_model_enabled?
|
|
68
68
|
subclass.setup_authorizer_classes
|
|
69
|
+
subclass.validate_command_groups!
|
|
69
70
|
tp.disable
|
|
70
71
|
end
|
|
71
72
|
end.enable
|
|
@@ -337,6 +338,55 @@ module Yes
|
|
|
337
338
|
@commands ||= {}
|
|
338
339
|
end
|
|
339
340
|
|
|
341
|
+
# Defines a command_group on the aggregate.
|
|
342
|
+
#
|
|
343
|
+
# A command_group declares a compound action that runs multiple
|
|
344
|
+
# existing aggregate commands in declaration order, atomically, with
|
|
345
|
+
# the sub-commands' guards bypassed. The group itself has its own,
|
|
346
|
+
# leaner guard set declared inside the block via `guard :name`.
|
|
347
|
+
#
|
|
348
|
+
# @param name [Symbol] the group name (also the aggregate method name)
|
|
349
|
+
# @yield Block accepting `command :sub_name` and `guard :name`
|
|
350
|
+
# @return [void]
|
|
351
|
+
#
|
|
352
|
+
# @example
|
|
353
|
+
# command_group :create_apprenticeship do
|
|
354
|
+
# command :assign_company
|
|
355
|
+
# command :assign_user
|
|
356
|
+
# command :change_name
|
|
357
|
+
# command :publish
|
|
358
|
+
#
|
|
359
|
+
# guard(:company_assigned) { payload.company_id.present? }
|
|
360
|
+
# end
|
|
361
|
+
def command_group(name, &)
|
|
362
|
+
@command_groups ||= {}
|
|
363
|
+
group_data = Dsl::CommandGroupData.new(name, self, context:, aggregate:)
|
|
364
|
+
@command_groups[name] = group_data
|
|
365
|
+
Dsl::CommandGroupDefiner.new(group_data).call(&)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# @return [Hash] The command groups defined on this aggregate
|
|
369
|
+
def command_groups
|
|
370
|
+
@command_groups ||= {}
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Validates that each command_group references commands actually
|
|
374
|
+
# defined on this aggregate. Called from the end-of-class
|
|
375
|
+
# {TracePoint} hook set up in {.inherited}.
|
|
376
|
+
#
|
|
377
|
+
# @raise [Dsl::CommandGroupDefiner::UnknownSubCommandError]
|
|
378
|
+
# @return [void]
|
|
379
|
+
def validate_command_groups!
|
|
380
|
+
command_groups.each do |group_name, data|
|
|
381
|
+
unknown = data.sub_command_names - commands.keys
|
|
382
|
+
next if unknown.empty?
|
|
383
|
+
|
|
384
|
+
raise Dsl::CommandGroupDefiner::UnknownSubCommandError,
|
|
385
|
+
"command_group :#{group_name} on #{name} references unknown commands: " \
|
|
386
|
+
"#{unknown.join(', ')}. Define them with `command :<name>` before using them."
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
340
390
|
private
|
|
341
391
|
|
|
342
392
|
#
|
|
@@ -36,7 +36,8 @@ module Yes
|
|
|
36
36
|
|
|
37
37
|
raise_command_unauthorized_error!(decision)
|
|
38
38
|
end
|
|
39
|
-
otl_trackable :call,
|
|
39
|
+
otl_trackable :call,
|
|
40
|
+
OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Command', track_sql: true)
|
|
40
41
|
|
|
41
42
|
private
|
|
42
43
|
|
|
@@ -60,6 +61,10 @@ module Yes
|
|
|
60
61
|
raise self::CommandNotAuthorized, msg
|
|
61
62
|
end
|
|
62
63
|
|
|
64
|
+
# Loads the resource the command acts on. Commands batched into one request
|
|
65
|
+
# commonly target the same resource, so the lookup is cached for the duration
|
|
66
|
+
# of the authorization pass (see {LookupCache}).
|
|
67
|
+
#
|
|
63
68
|
# @param command [Yes::Core::Command] command to authorize
|
|
64
69
|
# @return [ActiveRecord::Base] resource to authorize
|
|
65
70
|
# @raise [StandardError] if RESOURCE[:name] or RESOURCE[:read_model] is not defined
|
|
@@ -70,7 +75,10 @@ module Yes
|
|
|
70
75
|
raise StandardError, message
|
|
71
76
|
end
|
|
72
77
|
|
|
73
|
-
read_model(command)
|
|
78
|
+
model = read_model(command)
|
|
79
|
+
id = command.send("#{self::RESOURCE[:name]}_id")
|
|
80
|
+
|
|
81
|
+
LookupCache.fetch([:resource, model, id]) { model.find_by(id:) }
|
|
74
82
|
end
|
|
75
83
|
|
|
76
84
|
# Returns the appropriate read model class for the command.
|
|
@@ -145,12 +153,19 @@ module Yes
|
|
|
145
153
|
resource&.try(:auth_attributes)&.as_json || {}
|
|
146
154
|
end
|
|
147
155
|
|
|
156
|
+
# Builds the principal data for the request. It is derived purely from the
|
|
157
|
+
# auth data, which is the same for every command of a batch, so it is built
|
|
158
|
+
# once per authorization pass (see {LookupCache}). The result is shared
|
|
159
|
+
# between commands and must not be mutated.
|
|
160
|
+
#
|
|
148
161
|
# @param auth_data [Hash] authorization data
|
|
149
162
|
# @return [Hash] principal data for Cerbos check_resource
|
|
150
163
|
def principal_data(auth_data)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
164
|
+
data = auth_data.with_indifferent_access
|
|
165
|
+
|
|
166
|
+
LookupCache.fetch([:principal_data, data]) do
|
|
167
|
+
Yes::Core.configuration.cerbos_principal_data_builder.call(data)
|
|
168
|
+
end
|
|
154
169
|
end
|
|
155
170
|
|
|
156
171
|
# @param auth_data [Hash]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Authorization
|
|
6
|
+
# Scoped memoization for the read-only lookups an authorization pass repeats.
|
|
7
|
+
#
|
|
8
|
+
# Authorizing a batch of commands resolves the same two things once per command:
|
|
9
|
+
# the principal data (which depends only on the request's auth data) and the
|
|
10
|
+
# authorized resource (which the commands of a batch commonly share). Both are
|
|
11
|
+
# pure reads, and a batch is authorized in full before any of its commands is
|
|
12
|
+
# executed, so no write can invalidate them while the pass is running.
|
|
13
|
+
#
|
|
14
|
+
# Caching is only active inside {.with_scope}. Outside one, {.fetch} just yields,
|
|
15
|
+
# so callers keep their uncached behaviour unless they opt in. The store lives in
|
|
16
|
+
# ActiveSupport::IsolatedExecutionState, which keeps it per thread/fiber, and
|
|
17
|
+
# {.with_scope} always clears it on the way out so nothing leaks into the next
|
|
18
|
+
# request.
|
|
19
|
+
#
|
|
20
|
+
# Cached values are shared by every {.fetch} for the same key, so callers must
|
|
21
|
+
# treat them as read-only.
|
|
22
|
+
#
|
|
23
|
+
# @example Caching the lookups of one authorization pass
|
|
24
|
+
# LookupCache.with_scope do
|
|
25
|
+
# commands.each { |command| authorizer_for(command).call(command, auth_data) }
|
|
26
|
+
# end
|
|
27
|
+
class LookupCache
|
|
28
|
+
STORE_KEY = :yes_core_authorization_lookup_cache
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Runs the block with caching enabled, clearing the cache afterwards.
|
|
32
|
+
# A nested scope reuses the cache of the outermost one and leaves clearing
|
|
33
|
+
# to it.
|
|
34
|
+
#
|
|
35
|
+
# @yield the block to run with caching enabled
|
|
36
|
+
# @return [Object] the block's return value
|
|
37
|
+
def with_scope
|
|
38
|
+
return yield if active?
|
|
39
|
+
|
|
40
|
+
ActiveSupport::IsolatedExecutionState[STORE_KEY] = {}
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
yield
|
|
44
|
+
ensure
|
|
45
|
+
ActiveSupport::IsolatedExecutionState.delete(STORE_KEY)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns the value cached under key, computing it via the block on a miss.
|
|
50
|
+
# Without an open scope the block's value is returned uncached.
|
|
51
|
+
#
|
|
52
|
+
# @param key [Object] cache key
|
|
53
|
+
# @yield computes the value when it is not cached yet
|
|
54
|
+
# @return [Object] the cached or freshly computed value
|
|
55
|
+
def fetch(key)
|
|
56
|
+
return yield unless active?
|
|
57
|
+
|
|
58
|
+
store = ActiveSupport::IsolatedExecutionState[STORE_KEY]
|
|
59
|
+
return store[key] if store.key?(key)
|
|
60
|
+
|
|
61
|
+
store[key] = yield
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Boolean] whether a scope is currently open
|
|
65
|
+
def active?
|
|
66
|
+
ActiveSupport::IsolatedExecutionState.key?(STORE_KEY)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|