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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/services/RULE.md +269 -0
  3. data/.cursor/rules/services-rspec/RULE.md +354 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +77 -0
  6. data/.gitignore +25 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +134 -0
  9. data/.ruby-version +1 -0
  10. data/.vscode/cspell.json +18 -0
  11. data/.vscode/project-words.txt +12 -0
  12. data/AGENTS.md +139 -0
  13. data/CHANGELOG.md +111 -0
  14. data/CLAUDE.md +139 -0
  15. data/CODE_OF_CONDUCT.md +74 -0
  16. data/Gemfile +28 -0
  17. data/Gemfile.lock +149 -0
  18. data/LICENSE.txt +21 -0
  19. data/README.md +172 -0
  20. data/Rakefile +8 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/config/default.yml +57 -0
  24. data/docs/README.md +105 -0
  25. data/docs/SUMMARY.md +31 -0
  26. data/docs/arguments.md +275 -0
  27. data/docs/best-practices.md +153 -0
  28. data/docs/callbacks.md +475 -0
  29. data/docs/concepts.md +79 -0
  30. data/docs/configuration.md +218 -0
  31. data/docs/context.md +128 -0
  32. data/docs/crud.md +525 -0
  33. data/docs/errors.md +331 -0
  34. data/docs/generators.md +250 -0
  35. data/docs/outputs.md +150 -0
  36. data/docs/pundit-authorization.md +320 -0
  37. data/docs/quickstart.md +133 -0
  38. data/docs/recipes.md +14 -0
  39. data/docs/rubocop.md +430 -0
  40. data/docs/ruby-lsp.md +121 -0
  41. data/docs/service-rendering.md +222 -0
  42. data/docs/sorbet-runtime.md +283 -0
  43. data/docs/steps.md +438 -0
  44. data/docs/tapioca.md +188 -0
  45. data/docs/testing.md +548 -0
  46. data/lib/generators/operandi/install/USAGE +15 -0
  47. data/lib/generators/operandi/install/install_generator.rb +45 -0
  48. data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
  49. data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
  50. data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
  51. data/lib/generators/operandi/service/USAGE +21 -0
  52. data/lib/generators/operandi/service/service_generator.rb +80 -0
  53. data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
  54. data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
  55. data/lib/operandi/base.rb +230 -0
  56. data/lib/operandi/base_with_context.rb +57 -0
  57. data/lib/operandi/callbacks.rb +353 -0
  58. data/lib/operandi/collection.rb +166 -0
  59. data/lib/operandi/concerns/execution.rb +80 -0
  60. data/lib/operandi/concerns/parent_service.rb +32 -0
  61. data/lib/operandi/concerns/state_management.rb +34 -0
  62. data/lib/operandi/config.rb +142 -0
  63. data/lib/operandi/constants.rb +96 -0
  64. data/lib/operandi/dsl/arguments_dsl.rb +83 -0
  65. data/lib/operandi/dsl/outputs_dsl.rb +79 -0
  66. data/lib/operandi/dsl/steps_dsl.rb +206 -0
  67. data/lib/operandi/dsl/validation.rb +171 -0
  68. data/lib/operandi/exceptions.rb +66 -0
  69. data/lib/operandi/message.rb +52 -0
  70. data/lib/operandi/messages.rb +185 -0
  71. data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
  72. data/lib/operandi/rspec/matchers/define_output.rb +145 -0
  73. data/lib/operandi/rspec/matchers/define_step.rb +223 -0
  74. data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
  75. data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
  76. data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
  77. data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
  78. data/lib/operandi/rspec.rb +15 -0
  79. data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
  80. data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
  81. data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
  82. data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
  83. data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
  84. data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
  85. data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
  86. data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
  87. data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
  88. data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
  89. data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
  90. data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
  91. data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
  92. data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
  93. data/lib/operandi/rubocop.rb +22 -0
  94. data/lib/operandi/settings/field.rb +147 -0
  95. data/lib/operandi/settings/step.rb +105 -0
  96. data/lib/operandi/utils.rb +36 -0
  97. data/lib/operandi/version.rb +5 -0
  98. data/lib/operandi.rb +11 -0
  99. data/lib/ruby_lsp/operandi/addon.rb +37 -0
  100. data/lib/ruby_lsp/operandi/definition.rb +134 -0
  101. data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
  102. data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
  103. data/operandi.gemspec +33 -0
  104. data/rbi/operandi.rbi +197 -0
  105. data/sorbet/cache/data.mdb +0 -0
  106. data/sorbet/cache/lock.mdb +0 -0
  107. metadata +151 -0
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Operandi
6
+ module Generators
7
+ class ServiceGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ argument :name,
11
+ type: :string,
12
+ required: true,
13
+ desc: "The name of the service (e.g., user/create or CreateUser)"
14
+
15
+ class_option :args,
16
+ type: :array,
17
+ default: [],
18
+ desc: "List of arguments for the service"
19
+ class_option :steps,
20
+ type: :array,
21
+ default: [],
22
+ desc: "List of steps for the service"
23
+ class_option :outputs,
24
+ type: :array,
25
+ default: [],
26
+ desc: "List of outputs for the service"
27
+ class_option :skip_spec,
28
+ type: :boolean,
29
+ default: false,
30
+ desc: "Skip creating the spec file"
31
+ class_option :parent,
32
+ type: :string,
33
+ default: "ApplicationService",
34
+ desc: "Parent class for the service"
35
+
36
+ desc "Creates a new service class"
37
+
38
+ def create_service_file
39
+ template "service.rb.tt", "app/services/#{file_path}.rb"
40
+ end
41
+
42
+ def create_spec_file
43
+ return if options[:skip_spec]
44
+ return unless rspec_installed?
45
+
46
+ template "service_spec.rb.tt", "spec/services/#{file_path}_spec.rb"
47
+ end
48
+
49
+ private
50
+
51
+ def file_path
52
+ name.underscore
53
+ end
54
+
55
+ def class_name
56
+ name.camelize
57
+ end
58
+
59
+ def parent_class
60
+ options[:parent]
61
+ end
62
+
63
+ def arguments
64
+ options[:args]
65
+ end
66
+
67
+ def steps
68
+ options[:steps]
69
+ end
70
+
71
+ def outputs
72
+ options[:outputs]
73
+ end
74
+
75
+ def rspec_installed?
76
+ File.directory?(File.join(destination_root, "spec"))
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < <%= parent_class %>
4
+ <% if arguments.any? -%>
5
+ # Arguments
6
+ <% arguments.each do |arg| -%>
7
+ arg :<%= arg %>
8
+ <% end -%>
9
+
10
+ <% end -%>
11
+ <% if steps.any? -%>
12
+ # Steps
13
+ <% steps.each do |step| -%>
14
+ step :<%= step %>
15
+ <% end -%>
16
+
17
+ <% end -%>
18
+ <% if outputs.any? -%>
19
+ # Outputs
20
+ <% outputs.each do |output| -%>
21
+ output :<%= output %>
22
+ <% end -%>
23
+
24
+ <% end -%>
25
+ <% if steps.empty? -%>
26
+ # step :step_a
27
+ # step :step_b
28
+
29
+ <% end -%>
30
+ private
31
+
32
+ <% if steps.any? -%>
33
+ <% steps.each_with_index do |step, index| -%>
34
+ def <%= step %>
35
+ # TODO: Implement <%= step %>
36
+ end
37
+ <%= "\n" unless index == steps.length - 1 -%>
38
+ <% end -%>
39
+ <% else -%>
40
+ # def step_a
41
+ # # TODO: Implement service logic
42
+ # end
43
+
44
+ # def step_b
45
+ # # TODO: Implement service logic
46
+ # end
47
+ <% end -%>
48
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ # TODO: Add test implementation
6
+ RSpec.describe <%= class_name %>, type: :service do
7
+ <% if arguments.any? -%>
8
+ describe "arguments" do
9
+ <% arguments.each do |arg| -%>
10
+ it { is_expected.to define_argument(:<%= arg %>) }
11
+ <% end -%>
12
+ end
13
+
14
+ <% end -%>
15
+ <% if steps.any? -%>
16
+ describe "steps" do
17
+ <% steps.each do |step| -%>
18
+ it { is_expected.to define_step(:<%= step %>) }
19
+ <% end -%>
20
+ end
21
+
22
+ <% end -%>
23
+ <% if outputs.any? -%>
24
+ describe "outputs" do
25
+ <% outputs.each do |output| -%>
26
+ it { is_expected.to define_output(:<%= output %>) }
27
+ <% end -%>
28
+ end
29
+
30
+ <% end -%>
31
+ describe "#run" do
32
+ subject(:service) { described_class.run(args) }
33
+
34
+ let(:args) { {} }
35
+
36
+ it "succeeds" do
37
+ expect(service).to be_successful
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operandi/constants"
4
+ require "operandi/message"
5
+ require "operandi/messages"
6
+ require "operandi/base_with_context"
7
+
8
+ require "operandi/settings/step"
9
+ require "operandi/settings/field"
10
+
11
+ require "operandi/collection"
12
+
13
+ require "operandi/dsl/arguments_dsl"
14
+ require "operandi/dsl/outputs_dsl"
15
+ require "operandi/dsl/steps_dsl"
16
+
17
+ require "operandi/concerns/execution"
18
+ require "operandi/concerns/state_management"
19
+ require "operandi/concerns/parent_service"
20
+
21
+ # Base class for all service objects
22
+ module Operandi
23
+ # Base class for building service objects with arguments, outputs, and steps.
24
+ #
25
+ # @example Basic service
26
+ # class CreateUser < Operandi::Base
27
+ # arg :name, type: String
28
+ # arg :email, type: String
29
+ #
30
+ # output :user, type: User
31
+ #
32
+ # step :create_user
33
+ #
34
+ # private
35
+ #
36
+ # def create_user
37
+ # self.user = User.create!(name: name, email: email)
38
+ # end
39
+ # end
40
+ #
41
+ # result = CreateUser.run(name: "John", email: "john@example.com")
42
+ # result.success? # => true
43
+ # result.user # => #<User id: 1, name: "John">
44
+ class Base
45
+ extend CallbackDsl
46
+ include Callbacks
47
+ include Dsl::ArgumentsDsl
48
+ include Dsl::OutputsDsl
49
+ include Dsl::StepsDsl
50
+ include Concerns::Execution
51
+ include Concerns::StateManagement
52
+ include Concerns::ParentService
53
+
54
+ # @return [Collection::Base] collection of argument values
55
+ attr_reader :arg
56
+
57
+ # @return [Collection::Base] collection of output values
58
+ attr_reader :output
59
+
60
+ # @return [Messages] collection of error messages
61
+ attr_reader :errors
62
+
63
+ # @return [Messages] collection of warning messages
64
+ attr_reader :warnings
65
+
66
+ # Initialize a new service instance.
67
+ #
68
+ # @param args [Hash] arguments to pass to the service
69
+ # @param config [Hash] runtime configuration overrides
70
+ # @param parent_service [Base, nil] parent service for nested calls
71
+ def initialize(args = {}, config = {}, parent_service = nil)
72
+ @config = Operandi.config.merge(self.class.class_config || {}).merge(config)
73
+ @parent_service = parent_service
74
+
75
+ @output = Collection::Base.new(self, CollectionTypes::OUTPUTS)
76
+ @arg = Collection::Base.new(self, CollectionTypes::ARGUMENTS, args.dup)
77
+
78
+ @stopped = false
79
+ @launched_steps = []
80
+
81
+ initialize_errors
82
+ initialize_warnings
83
+ end
84
+
85
+ # Check if the service completed without errors.
86
+ #
87
+ # @return [Boolean] true if no errors were added
88
+ def success?
89
+ !errors?
90
+ end
91
+ alias successful? success?
92
+
93
+ # Check if the service completed with errors.
94
+ #
95
+ # @return [Boolean] true if any errors were added
96
+ def failed?
97
+ errors?
98
+ end
99
+
100
+ # Check if the service has any errors.
101
+ #
102
+ # @return [Boolean] true if errors collection is not empty
103
+ def errors?
104
+ @errors.any?
105
+ end
106
+
107
+ # Check if the service has any warnings.
108
+ #
109
+ # @return [Boolean] true if warnings collection is not empty
110
+ def warnings?
111
+ @warnings.any?
112
+ end
113
+
114
+ # Stop executing remaining steps after the current step completes.
115
+ #
116
+ # @return [Boolean] true
117
+ def stop!
118
+ @stopped = true
119
+ end
120
+
121
+ # Check if the service has been stopped.
122
+ #
123
+ # @return [Boolean] true if stop! was called
124
+ def stopped?
125
+ @stopped
126
+ end
127
+
128
+ # Stop execution immediately, skipping any remaining code in the current step.
129
+ #
130
+ # @raise [StopExecution] always raises to halt execution
131
+ # @return [void]
132
+ def stop_immediately!
133
+ @stopped = true
134
+ raise Operandi::StopExecution
135
+ end
136
+
137
+ # Add an error to the :base key.
138
+ #
139
+ # @param message [String] the error message
140
+ # @return [void]
141
+ def fail!(message)
142
+ errors.add(:base, message)
143
+ end
144
+
145
+ # Add an error and stop execution immediately, causing transaction rollback.
146
+ # Steps marked with `always: true` will still run after this method is called.
147
+ #
148
+ # @param message [String] the error message
149
+ # @raise [FailExecution] always raises to halt execution and rollback
150
+ # @return [void]
151
+ def fail_immediately!(message)
152
+ errors.add(:base, message, rollback: false)
153
+ raise Operandi::FailExecution
154
+ end
155
+
156
+ # Execute the service steps.
157
+ #
158
+ # @return [void]
159
+ # @raise [StandardError] re-raises any exception after running always steps
160
+ def call
161
+ load_defaults_and_validate
162
+
163
+ run_callbacks(:before_service_run, self)
164
+
165
+ run_callbacks(:around_service_run, self) do
166
+ execute_service
167
+ end
168
+
169
+ run_service_result_callbacks
170
+ rescue StandardError => e
171
+ run_steps_with_always
172
+ raise e
173
+ end
174
+
175
+ class << self
176
+ # @return [Hash, nil] class-level configuration options
177
+ attr_accessor :class_config
178
+
179
+ # Set class-level configuration for this service.
180
+ #
181
+ # @param config [Hash] configuration options
182
+ # @return [Hash] the configuration hash
183
+ def config(config = {})
184
+ self.class_config = config
185
+ end
186
+
187
+ # Run the service and return the result.
188
+ #
189
+ # @param kwargs [Hash] keyword arguments matching service arguments
190
+ # @return [Base] the executed service instance
191
+ #
192
+ # @example
193
+ # result = MyService.run(name: "test")
194
+ # result.success? # => true
195
+ def run(**kwargs)
196
+ new(kwargs).tap(&:call)
197
+ end
198
+
199
+ # Run the service and raise an error if it fails.
200
+ #
201
+ # @param kwargs [Hash] keyword arguments matching service arguments
202
+ # @return [Base] the executed service instance
203
+ # @raise [RuntimeError] if the service fails
204
+ #
205
+ # @example
206
+ # MyService.run!(name: "test") # raises if service fails
207
+ def run!(**kwargs)
208
+ new(kwargs, { raise_on_error: true }).tap(&:call)
209
+ end
210
+
211
+ # Create a context for running the service with a parent service or config.
212
+ #
213
+ # @param service_or_config [Base, Hash] parent service or configuration hash
214
+ # @param config [Hash] configuration hash (when first param is a service)
215
+ # @return [BaseWithContext] context wrapper for running the service
216
+ #
217
+ # @example With parent service
218
+ # ChildService.with(self).run(data: value)
219
+ #
220
+ # @example With configuration
221
+ # MyService.with(use_transactions: false).run(name: "test")
222
+ def with(service_or_config, config = {})
223
+ service = service_or_config.is_a?(Hash) ? nil : service_or_config
224
+ config = service_or_config unless service
225
+
226
+ BaseWithContext.new(self, service, config.dup)
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ # Wrapper for running a service with a parent context or custom configuration.
5
+ # Created via {Base.with} method.
6
+ #
7
+ # @example Running with parent service context
8
+ # ChildService.with(self).run(data: value)
9
+ #
10
+ # @example Running with custom configuration
11
+ # MyService.with(use_transactions: false).run(name: "test")
12
+ class BaseWithContext
13
+ # Initialize a new context wrapper.
14
+ #
15
+ # @param service_class [Class] the service class to run
16
+ # @param parent_service [Base, nil] parent service for error/warning propagation
17
+ # @param config [Hash] configuration overrides
18
+ # @raise [ArgTypeError] if parent_service is not a Base subclass
19
+ def initialize(service_class, parent_service, config)
20
+ @service_class = service_class
21
+ @config = config
22
+ @parent_service = parent_service
23
+
24
+ return if parent_service.nil? || parent_service.is_a?(Operandi::Base)
25
+
26
+ message = "#{parent_service.class} - must be a subclass of Operandi::Base"
27
+ raise Operandi::ArgTypeError.new(message, service_class: @service_class)
28
+ end
29
+
30
+ # Run the service with the configured context.
31
+ #
32
+ # @param kwargs [Hash] keyword arguments matching service arguments
33
+ # @return [Base] the executed service instance
34
+ def run(**kwargs)
35
+ @service_class.new(extend_arguments(kwargs), @config, @parent_service).tap(&:call)
36
+ end
37
+
38
+ # Run the service and raise an error if it fails.
39
+ #
40
+ # @param kwargs [Hash] keyword arguments matching service arguments
41
+ # @return [Base] the executed service instance
42
+ # @raise [RuntimeError] if the service fails
43
+ def run!(**kwargs)
44
+ @config[:raise_on_error] = true
45
+ run(**kwargs)
46
+ end
47
+
48
+ private
49
+
50
+ def extend_arguments(args)
51
+ args = @parent_service.arg.dup.extend_with_context(args) if @parent_service
52
+ args[:deepness] += 1 if args[:deepness]
53
+
54
+ args
55
+ end
56
+ end
57
+ end