operandi 5.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/.cursor/rules/services/RULE.md +269 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/.ruby-version +1 -0
- data/.vscode/cspell.json +18 -0
- data/.vscode/project-words.txt +12 -0
- data/AGENTS.md +139 -0
- data/CHANGELOG.md +111 -0
- data/CLAUDE.md +139 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +28 -0
- data/Gemfile.lock +149 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +57 -0
- data/docs/README.md +105 -0
- data/docs/SUMMARY.md +31 -0
- data/docs/arguments.md +275 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +475 -0
- data/docs/concepts.md +79 -0
- data/docs/configuration.md +218 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +331 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +150 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +133 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +430 -0
- data/docs/ruby-lsp.md +121 -0
- data/docs/service-rendering.md +222 -0
- data/docs/sorbet-runtime.md +283 -0
- data/docs/steps.md +438 -0
- data/docs/tapioca.md +188 -0
- data/docs/testing.md +548 -0
- data/lib/generators/operandi/install/USAGE +15 -0
- data/lib/generators/operandi/install/install_generator.rb +45 -0
- data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/operandi/service/USAGE +21 -0
- data/lib/generators/operandi/service/service_generator.rb +80 -0
- data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
- data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
- data/lib/operandi/base.rb +230 -0
- data/lib/operandi/base_with_context.rb +57 -0
- data/lib/operandi/callbacks.rb +353 -0
- data/lib/operandi/collection.rb +166 -0
- data/lib/operandi/concerns/execution.rb +80 -0
- data/lib/operandi/concerns/parent_service.rb +32 -0
- data/lib/operandi/concerns/state_management.rb +34 -0
- data/lib/operandi/config.rb +142 -0
- data/lib/operandi/constants.rb +96 -0
- data/lib/operandi/dsl/arguments_dsl.rb +83 -0
- data/lib/operandi/dsl/outputs_dsl.rb +79 -0
- data/lib/operandi/dsl/steps_dsl.rb +206 -0
- data/lib/operandi/dsl/validation.rb +171 -0
- data/lib/operandi/exceptions.rb +66 -0
- data/lib/operandi/message.rb +52 -0
- data/lib/operandi/messages.rb +185 -0
- data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
- data/lib/operandi/rspec/matchers/define_output.rb +145 -0
- data/lib/operandi/rspec/matchers/define_step.rb +223 -0
- data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
- data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
- data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
- data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
- data/lib/operandi/rspec.rb +15 -0
- data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
- data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
- data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
- data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
- data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
- data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
- data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
- data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
- data/lib/operandi/rubocop.rb +22 -0
- data/lib/operandi/settings/field.rb +147 -0
- data/lib/operandi/settings/step.rb +105 -0
- data/lib/operandi/utils.rb +36 -0
- data/lib/operandi/version.rb +5 -0
- data/lib/operandi.rb +11 -0
- data/lib/ruby_lsp/operandi/addon.rb +37 -0
- data/lib/ruby_lsp/operandi/definition.rb +134 -0
- data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
- data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
- data/operandi.gemspec +33 -0
- data/rbi/operandi.rbi +197 -0
- data/sorbet/cache/data.mdb +0 -0
- data/sorbet/cache/lock.mdb +0 -0
- metadata +151 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
class << self
|
|
5
|
+
# Configure Operandi with a block.
|
|
6
|
+
#
|
|
7
|
+
# @yield [Config] the configuration object
|
|
8
|
+
# @return [void]
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# Operandi.configure do |config|
|
|
12
|
+
# config.require_arg_type = true
|
|
13
|
+
# config.require_output_type = true
|
|
14
|
+
# config.use_transactions = false
|
|
15
|
+
# end
|
|
16
|
+
def configure
|
|
17
|
+
yield config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Get the global configuration object.
|
|
21
|
+
#
|
|
22
|
+
# @return [Config] the configuration instance
|
|
23
|
+
def config
|
|
24
|
+
@config ||= Config.new
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Configuration class for Operandi global settings.
|
|
29
|
+
#
|
|
30
|
+
# @example Accessing configuration
|
|
31
|
+
# Operandi.config.require_arg_type # => true
|
|
32
|
+
#
|
|
33
|
+
# @example Modifying configuration
|
|
34
|
+
# Operandi.config.use_transactions = false
|
|
35
|
+
class Config
|
|
36
|
+
# @return [Boolean] whether arguments must have a type specified
|
|
37
|
+
attr_reader :require_arg_type
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] whether outputs must have a type specified
|
|
40
|
+
attr_reader :require_output_type
|
|
41
|
+
|
|
42
|
+
# @return [Boolean] whether to wrap service execution in a database transaction
|
|
43
|
+
attr_reader :use_transactions
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] whether to copy errors to parent service in chain
|
|
46
|
+
attr_reader :load_errors
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] whether to stop executing steps when an error is added
|
|
49
|
+
attr_reader :break_on_error
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] whether to raise Operandi::RuntimeError when service fails
|
|
52
|
+
attr_reader :raise_on_error
|
|
53
|
+
|
|
54
|
+
# @return [Boolean] whether to rollback the transaction when an error is added
|
|
55
|
+
attr_reader :rollback_on_error
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] whether to copy warnings to parent service in chain
|
|
58
|
+
attr_reader :load_warnings
|
|
59
|
+
|
|
60
|
+
# @return [Boolean] whether to stop executing steps when a warning is added
|
|
61
|
+
attr_reader :break_on_warning
|
|
62
|
+
|
|
63
|
+
# @return [Boolean] whether to raise Operandi::RuntimeError when service has warnings
|
|
64
|
+
attr_reader :raise_on_warning
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] whether to rollback the transaction when a warning is added
|
|
67
|
+
attr_reader :rollback_on_warning
|
|
68
|
+
|
|
69
|
+
# @return [Hash{String => String}] custom type mappings for Ruby LSP addon.
|
|
70
|
+
# Maps custom types to Ruby types for hover/completion.
|
|
71
|
+
# @example { "CustomTypes::UUID" => "String", "CustomTypes::Money" => "BigDecimal" }
|
|
72
|
+
attr_reader :ruby_lsp_type_mappings
|
|
73
|
+
|
|
74
|
+
DEFAULTS = {
|
|
75
|
+
require_arg_type: true,
|
|
76
|
+
require_output_type: true,
|
|
77
|
+
use_transactions: true,
|
|
78
|
+
|
|
79
|
+
load_errors: true,
|
|
80
|
+
break_on_error: true,
|
|
81
|
+
raise_on_error: false,
|
|
82
|
+
rollback_on_error: true,
|
|
83
|
+
|
|
84
|
+
load_warnings: true,
|
|
85
|
+
break_on_warning: false,
|
|
86
|
+
raise_on_warning: false,
|
|
87
|
+
rollback_on_warning: false,
|
|
88
|
+
|
|
89
|
+
ruby_lsp_type_mappings: {}.freeze,
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
DEFAULTS.each_key do |name|
|
|
93
|
+
define_method(:"#{name}=") do |value|
|
|
94
|
+
instance_variable_set(:"@#{name}", value)
|
|
95
|
+
@to_h = nil # Invalidate memoized hash
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Convenience setter for backward compatibility.
|
|
100
|
+
# Sets both require_arg_type and require_output_type.
|
|
101
|
+
#
|
|
102
|
+
# @param value [Boolean] whether to require types for arguments and outputs
|
|
103
|
+
# @return [void]
|
|
104
|
+
def require_type=(value)
|
|
105
|
+
self.require_arg_type = value
|
|
106
|
+
self.require_output_type = value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Initialize configuration with default values.
|
|
110
|
+
def initialize
|
|
111
|
+
reset_to_defaults!
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Reset all configuration options to their default values.
|
|
115
|
+
#
|
|
116
|
+
# @return [void]
|
|
117
|
+
def reset_to_defaults!
|
|
118
|
+
DEFAULTS.each do |key, value|
|
|
119
|
+
instance_variable_set(:"@#{key}", value)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@to_h = nil # Invalidate memoized hash
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Convert configuration to a hash.
|
|
126
|
+
#
|
|
127
|
+
# @return [Hash{Symbol => Object}] all configuration options as a hash
|
|
128
|
+
def to_h
|
|
129
|
+
@to_h ||= DEFAULTS.keys.to_h do |key|
|
|
130
|
+
[key, public_send(key)]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Merge configuration with additional options.
|
|
135
|
+
#
|
|
136
|
+
# @param config [Hash] options to merge
|
|
137
|
+
# @return [Hash] merged configuration hash
|
|
138
|
+
def merge(config)
|
|
139
|
+
to_h.merge(config)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
# Collection type constants
|
|
5
|
+
module CollectionTypes
|
|
6
|
+
ARGUMENTS = :arguments
|
|
7
|
+
OUTPUTS = :outputs
|
|
8
|
+
|
|
9
|
+
ALL = [ARGUMENTS, OUTPUTS].freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Field type constants
|
|
13
|
+
module FieldTypes
|
|
14
|
+
ARGUMENT = :argument
|
|
15
|
+
OUTPUT = :output
|
|
16
|
+
|
|
17
|
+
ALL = [ARGUMENT, OUTPUT].freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Reserved names that cannot be used for arguments, outputs, or steps
|
|
21
|
+
# These names would conflict with existing gem methods
|
|
22
|
+
module ReservedNames
|
|
23
|
+
# Instance methods from Base class and concerns
|
|
24
|
+
BASE_METHODS = [
|
|
25
|
+
:arg,
|
|
26
|
+
:output,
|
|
27
|
+
:errors,
|
|
28
|
+
:warnings,
|
|
29
|
+
:success?,
|
|
30
|
+
:failed?,
|
|
31
|
+
:errors?,
|
|
32
|
+
:warnings?,
|
|
33
|
+
:stop!,
|
|
34
|
+
:stopped?,
|
|
35
|
+
:stop_immediately!,
|
|
36
|
+
:call,
|
|
37
|
+
:run_callbacks,
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Class methods that could conflict
|
|
41
|
+
CLASS_METHODS = [
|
|
42
|
+
:config,
|
|
43
|
+
:run,
|
|
44
|
+
:run!,
|
|
45
|
+
:with,
|
|
46
|
+
:arg,
|
|
47
|
+
:remove_arg,
|
|
48
|
+
:output,
|
|
49
|
+
:remove_output,
|
|
50
|
+
:step,
|
|
51
|
+
:remove_step,
|
|
52
|
+
:steps,
|
|
53
|
+
:outputs,
|
|
54
|
+
:arguments,
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Callback method names
|
|
58
|
+
CALLBACK_METHODS = [
|
|
59
|
+
:before_step_run,
|
|
60
|
+
:after_step_run,
|
|
61
|
+
:around_step_run,
|
|
62
|
+
:on_step_success,
|
|
63
|
+
:on_step_failure,
|
|
64
|
+
:on_step_crash,
|
|
65
|
+
:before_service_run,
|
|
66
|
+
:after_service_run,
|
|
67
|
+
:around_service_run,
|
|
68
|
+
:on_service_success,
|
|
69
|
+
:on_service_failure,
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
# Ruby reserved words and common Object methods
|
|
73
|
+
RUBY_RESERVED = [
|
|
74
|
+
:initialize,
|
|
75
|
+
:class,
|
|
76
|
+
:object_id,
|
|
77
|
+
:send,
|
|
78
|
+
:__send__,
|
|
79
|
+
:public_send,
|
|
80
|
+
:respond_to?,
|
|
81
|
+
:method,
|
|
82
|
+
:methods,
|
|
83
|
+
:instance_variable_get,
|
|
84
|
+
:instance_variable_set,
|
|
85
|
+
:instance_variables,
|
|
86
|
+
:extend,
|
|
87
|
+
:include,
|
|
88
|
+
:new,
|
|
89
|
+
:allocate,
|
|
90
|
+
:superclass,
|
|
91
|
+
].freeze
|
|
92
|
+
|
|
93
|
+
# All reserved names combined (used for validation)
|
|
94
|
+
ALL = (BASE_METHODS + CALLBACK_METHODS + RUBY_RESERVED).uniq.freeze
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../constants"
|
|
4
|
+
require_relative "validation"
|
|
5
|
+
|
|
6
|
+
module Operandi
|
|
7
|
+
module Dsl
|
|
8
|
+
# DSL for defining and managing service arguments
|
|
9
|
+
module ArgumentsDsl
|
|
10
|
+
def self.included(base)
|
|
11
|
+
base.extend(ClassMethods)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module ClassMethods
|
|
15
|
+
# Define an argument for the service
|
|
16
|
+
#
|
|
17
|
+
# @param name [Symbol] the argument name
|
|
18
|
+
# @param opts [Hash] options for configuring the argument
|
|
19
|
+
# @option opts [Class, Array<Class>] :type Type(s) to validate against
|
|
20
|
+
# (e.g., String, Integer, [String, Symbol])
|
|
21
|
+
# @option opts [Boolean] :optional (false) Whether nil values are allowed
|
|
22
|
+
# @option opts [Object, Proc] :default Default value or proc to evaluate in instance context
|
|
23
|
+
# @option opts [Boolean] :context (false) Whether to pass this argument to child services
|
|
24
|
+
#
|
|
25
|
+
# @example Define a required string argument
|
|
26
|
+
# arg :name, type: String
|
|
27
|
+
#
|
|
28
|
+
# @example Define an optional argument with default
|
|
29
|
+
# arg :age, type: Integer, optional: true, default: 25
|
|
30
|
+
#
|
|
31
|
+
# @example Define an argument with multiple allowed types
|
|
32
|
+
# arg :id, type: [String, Integer]
|
|
33
|
+
#
|
|
34
|
+
# @example Define an argument with proc default
|
|
35
|
+
# arg :timestamp, type: Time, default: -> { Time.now }
|
|
36
|
+
#
|
|
37
|
+
# @example Define a context argument passed to child services
|
|
38
|
+
# arg :current_user, type: User, context: true
|
|
39
|
+
def arg(name, opts = {})
|
|
40
|
+
Validation.validate_symbol_name!(name, :argument, self)
|
|
41
|
+
Validation.validate_reserved_name!(name, :argument, self)
|
|
42
|
+
Validation.validate_name_conflicts!(name, :argument, self)
|
|
43
|
+
Validation.validate_type_required!(name, :argument, self, opts)
|
|
44
|
+
|
|
45
|
+
own_arguments[name] = Settings::Field.new(name, self, opts.merge(field_type: FieldTypes::ARGUMENT))
|
|
46
|
+
@arguments = nil # Clear memoized arguments since we're modifying them
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Remove an argument from the service
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol] the argument name to remove
|
|
52
|
+
def remove_arg(name)
|
|
53
|
+
own_arguments.delete(name)
|
|
54
|
+
@arguments = nil # Clear memoized arguments since we're modifying them
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get all arguments including inherited ones
|
|
58
|
+
#
|
|
59
|
+
# @return [Hash] all arguments defined for this service
|
|
60
|
+
def arguments
|
|
61
|
+
@arguments ||= build_arguments
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get only arguments defined in this class
|
|
65
|
+
#
|
|
66
|
+
# @return [Hash] arguments defined in this class only
|
|
67
|
+
def own_arguments
|
|
68
|
+
@own_arguments ||= {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Build arguments by merging inherited arguments with own arguments
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash] merged arguments
|
|
76
|
+
def build_arguments
|
|
77
|
+
inherited = superclass.respond_to?(:arguments) ? superclass.arguments.dup : {}
|
|
78
|
+
inherited.merge(own_arguments)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../constants"
|
|
4
|
+
require_relative "validation"
|
|
5
|
+
|
|
6
|
+
module Operandi
|
|
7
|
+
module Dsl
|
|
8
|
+
# DSL for defining and managing service outputs
|
|
9
|
+
module OutputsDsl
|
|
10
|
+
def self.included(base)
|
|
11
|
+
base.extend(ClassMethods)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module ClassMethods
|
|
15
|
+
# Define an output for the service
|
|
16
|
+
#
|
|
17
|
+
# @param name [Symbol] the output name
|
|
18
|
+
# @param opts [Hash] options for configuring the output
|
|
19
|
+
# @option opts [Class, Array<Class>] :type Type(s) to validate against
|
|
20
|
+
# (e.g., Hash, String, [String, Symbol])
|
|
21
|
+
# @option opts [Boolean] :optional (false) Whether nil values are allowed
|
|
22
|
+
# @option opts [Object, Proc] :default Default value or proc to evaluate in instance context
|
|
23
|
+
#
|
|
24
|
+
# @example Define a required hash output
|
|
25
|
+
# output :result, type: Hash
|
|
26
|
+
#
|
|
27
|
+
# @example Define an optional output with default
|
|
28
|
+
# output :status, type: String, optional: true, default: "pending"
|
|
29
|
+
#
|
|
30
|
+
# @example Define an output with multiple allowed types
|
|
31
|
+
# output :data, type: [Hash, Array]
|
|
32
|
+
#
|
|
33
|
+
# @example Define an output with proc default
|
|
34
|
+
# output :metadata, type: Hash, default: -> { {} }
|
|
35
|
+
def output(name, opts = {})
|
|
36
|
+
Validation.validate_symbol_name!(name, :output, self)
|
|
37
|
+
Validation.validate_reserved_name!(name, :output, self)
|
|
38
|
+
Validation.validate_name_conflicts!(name, :output, self)
|
|
39
|
+
Validation.validate_type_required!(name, :output, self, opts)
|
|
40
|
+
|
|
41
|
+
own_outputs[name] = Settings::Field.new(name, self, opts.merge(field_type: FieldTypes::OUTPUT))
|
|
42
|
+
@outputs = nil # Clear memoized outputs since we're modifying them
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Remove an output from the service
|
|
46
|
+
#
|
|
47
|
+
# @param name [Symbol] the output name to remove
|
|
48
|
+
def remove_output(name)
|
|
49
|
+
own_outputs.delete(name)
|
|
50
|
+
@outputs = nil # Clear memoized outputs since we're modifying them
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get all outputs including inherited ones
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash] all outputs defined for this service
|
|
56
|
+
def outputs
|
|
57
|
+
@outputs ||= build_outputs
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get only outputs defined in this class
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash] outputs defined in this class only
|
|
63
|
+
def own_outputs
|
|
64
|
+
@own_outputs ||= {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Build outputs by merging inherited outputs with own outputs
|
|
70
|
+
#
|
|
71
|
+
# @return [Hash] merged outputs
|
|
72
|
+
def build_outputs
|
|
73
|
+
inherited = superclass.respond_to?(:outputs) ? superclass.outputs.dup : {}
|
|
74
|
+
inherited.merge(own_outputs)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../constants"
|
|
4
|
+
require_relative "validation"
|
|
5
|
+
|
|
6
|
+
module Operandi
|
|
7
|
+
module Dsl
|
|
8
|
+
# DSL for defining and managing service steps
|
|
9
|
+
module StepsDsl
|
|
10
|
+
def self.included(base)
|
|
11
|
+
base.extend(ClassMethods)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module ClassMethods
|
|
15
|
+
# Define a step for the service
|
|
16
|
+
#
|
|
17
|
+
# @param name [Symbol] the step name (must correspond to a private method)
|
|
18
|
+
# @param opts [Hash] options for configuring the step
|
|
19
|
+
# @option opts [Symbol, Proc] :if Condition to determine if step should run
|
|
20
|
+
# @option opts [Symbol, Proc] :unless Condition to skip step (returns truthy to skip)
|
|
21
|
+
# @option opts [Boolean] :always (false) Run step even after errors/warnings
|
|
22
|
+
# @option opts [Symbol] :before Insert this step before the specified step
|
|
23
|
+
# @option opts [Symbol] :after Insert this step after the specified step
|
|
24
|
+
#
|
|
25
|
+
# @example Define a simple step
|
|
26
|
+
# step :validate_input
|
|
27
|
+
#
|
|
28
|
+
# @example Define a conditional step
|
|
29
|
+
# step :send_notification, if: :should_notify?
|
|
30
|
+
# step :skip_validation, unless: :production?
|
|
31
|
+
#
|
|
32
|
+
# @example Define a step that always runs
|
|
33
|
+
# step :cleanup, always: true
|
|
34
|
+
#
|
|
35
|
+
# @example Define step ordering
|
|
36
|
+
# step :log_start, before: :validate_input
|
|
37
|
+
# step :log_end, after: :process_data
|
|
38
|
+
#
|
|
39
|
+
# @example Define a step with proc condition
|
|
40
|
+
# step :premium_feature, if: -> { user.premium? && feature_enabled? }
|
|
41
|
+
def step(name, opts = {}) # rubocop:disable Metrics/MethodLength
|
|
42
|
+
Validation.validate_symbol_name!(name, :step, self)
|
|
43
|
+
Validation.validate_reserved_name!(name, :step, self)
|
|
44
|
+
Validation.validate_name_conflicts!(name, :step, self)
|
|
45
|
+
validate_step_opts!(name, opts)
|
|
46
|
+
|
|
47
|
+
# Build current steps to check for duplicates and find insertion targets
|
|
48
|
+
current = steps
|
|
49
|
+
if current.key?(name)
|
|
50
|
+
raise Operandi::Error,
|
|
51
|
+
"Step `#{name}` is already defined in service #{self}. Each step must have a unique name."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if (target = opts[:before] || opts[:after]) && !current.key?(target)
|
|
55
|
+
available = current.keys.join(", ")
|
|
56
|
+
raise Operandi::Error,
|
|
57
|
+
"Cannot find target step `#{target}` in service #{self}. Available steps: [#{available}]"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
step_obj = Settings::Step.new(name, self, opts)
|
|
61
|
+
|
|
62
|
+
if opts[:before] || opts[:after]
|
|
63
|
+
step_operations << { action: :insert,
|
|
64
|
+
name: name,
|
|
65
|
+
step: step_obj,
|
|
66
|
+
before: opts[:before],
|
|
67
|
+
after: opts[:after], }
|
|
68
|
+
else
|
|
69
|
+
step_operations << { action: :add, name: name, step: step_obj }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Clear memoized steps since we're modifying them
|
|
73
|
+
@steps = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Remove a step from the service
|
|
77
|
+
#
|
|
78
|
+
# @param name [Symbol] the step name to remove
|
|
79
|
+
def remove_step(name)
|
|
80
|
+
step_operations << { action: :remove, name: name }
|
|
81
|
+
|
|
82
|
+
# Clear memoized steps since we're modifying them
|
|
83
|
+
@steps = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get all steps including inherited ones
|
|
87
|
+
#
|
|
88
|
+
# @return [Hash] all steps defined for this service
|
|
89
|
+
def steps
|
|
90
|
+
@steps ||= build_steps
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get the list of step operations to be applied
|
|
94
|
+
#
|
|
95
|
+
# @return [Array] list of operations
|
|
96
|
+
def step_operations
|
|
97
|
+
@step_operations ||= []
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Validate that the service has steps defined
|
|
101
|
+
# Called before executing the service
|
|
102
|
+
#
|
|
103
|
+
# @raise [NoStepsError] if no steps are defined
|
|
104
|
+
def validate_steps!
|
|
105
|
+
return unless steps.empty?
|
|
106
|
+
|
|
107
|
+
raise Operandi::NoStepsError,
|
|
108
|
+
"Service #{self} has no steps defined. Define at least one step or implement a `run` method."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Validate step options to ensure they are valid
|
|
114
|
+
#
|
|
115
|
+
# @param name [Symbol] the step name
|
|
116
|
+
# @param opts [Hash] the step options
|
|
117
|
+
def validate_step_opts!(name, opts)
|
|
118
|
+
return unless opts[:before] && opts[:after]
|
|
119
|
+
|
|
120
|
+
raise Operandi::Error, "You cannot specify `before` and `after` " \
|
|
121
|
+
"for step `#{name}` in service #{self} at the same time"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Build steps by applying operations to inherited steps
|
|
125
|
+
#
|
|
126
|
+
# @return [Hash] the final steps hash
|
|
127
|
+
def build_steps
|
|
128
|
+
# Start with inherited steps
|
|
129
|
+
result = inherit_steps
|
|
130
|
+
|
|
131
|
+
# Apply operations in order
|
|
132
|
+
step_operations.each { |op| apply_step_operation(result, op) }
|
|
133
|
+
|
|
134
|
+
# If no steps defined, check for `run` method as fallback
|
|
135
|
+
result[:run] = Settings::Step.new(:run, self, {}) if result.empty? && instance_method_defined?(:run)
|
|
136
|
+
|
|
137
|
+
result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Check if an instance method is defined in this class or its ancestors
|
|
141
|
+
# (excluding Operandi::Base and its modules)
|
|
142
|
+
#
|
|
143
|
+
# @param method_name [Symbol] the method name to check
|
|
144
|
+
# @return [Boolean] true if the method is defined
|
|
145
|
+
def instance_method_defined?(method_name)
|
|
146
|
+
# Check if method exists and is not from base service classes
|
|
147
|
+
return false unless method_defined?(method_name) || private_method_defined?(method_name)
|
|
148
|
+
|
|
149
|
+
# Get the method owner to ensure it's defined in user's service class
|
|
150
|
+
owner = instance_method(method_name).owner
|
|
151
|
+
|
|
152
|
+
# Method should be defined in a class that inherits from Base,
|
|
153
|
+
# not in Base itself or its included modules
|
|
154
|
+
!owner.to_s.start_with?("Operandi")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Inherit steps from parent class
|
|
158
|
+
#
|
|
159
|
+
# @return [Hash] inherited steps
|
|
160
|
+
def inherit_steps
|
|
161
|
+
superclass.respond_to?(:steps) ? superclass.steps.dup : {}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Apply a single step operation to the steps hash
|
|
165
|
+
#
|
|
166
|
+
# @param steps [Hash] the steps hash
|
|
167
|
+
# @param operation [Hash] the operation to apply
|
|
168
|
+
def apply_step_operation(steps, operation)
|
|
169
|
+
case operation[:action]
|
|
170
|
+
when :add
|
|
171
|
+
steps[operation[:name]] = operation[:step]
|
|
172
|
+
when :remove
|
|
173
|
+
steps.delete(operation[:name])
|
|
174
|
+
when :insert
|
|
175
|
+
insert_step(steps, operation)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Insert a step before or after a target step
|
|
180
|
+
#
|
|
181
|
+
# @param steps [Hash] the steps hash
|
|
182
|
+
# @param operation [Hash] the insert operation details
|
|
183
|
+
def insert_step(steps, operation)
|
|
184
|
+
target = operation[:before] || operation[:after]
|
|
185
|
+
keys = steps.keys
|
|
186
|
+
index = keys.index(target)
|
|
187
|
+
return unless index
|
|
188
|
+
|
|
189
|
+
# More efficient insertion using ordered hash reconstruction
|
|
190
|
+
new_steps = {}
|
|
191
|
+
|
|
192
|
+
keys.each_with_index do |key, i|
|
|
193
|
+
# Insert before target
|
|
194
|
+
new_steps[operation[:name]] = operation[:step] if operation[:before] && i == index
|
|
195
|
+
new_steps[key] = steps[key]
|
|
196
|
+
|
|
197
|
+
# Insert after target
|
|
198
|
+
new_steps[operation[:name]] = operation[:step] if operation[:after] && i == index
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
steps.replace(new_steps)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|