better_service 1.0.1 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c796a9b8cd27afc4ae26e29b42decb8a606d88d0fdd6158b8599766cfa33df48
4
- data.tar.gz: afdee95a722f85f08c919243b5ff8da37adb211b4d7b1dd3ba7a334e97d3b2f6
3
+ metadata.gz: 622ac1a705ab117672d0e9c4896e65b66585c747023d1e00b34824e9c35606a0
4
+ data.tar.gz: f4c6f5b6ae7baaa3f61eeb123d4a2a493a7c8acc2ff835c6891396ed543efa1c
5
5
  SHA512:
6
- metadata.gz: 5fb831ec7c495b453b0915c33427080012faac9aa870919d7e0e30303251fd477c9309ccfb0e8c6e257d36fe45b9c03f7af7445290641f4af898d6f62ab5c800
7
- data.tar.gz: f34a008657a3f9208c220d286f3c33201daadac7a7114c729d97e3a5734574907cce3ce0fc85c6ea6964ab8339266be0b31d9c6f67a5211d0eb1c7b7ff18a176
6
+ metadata.gz: d4f06ae88607c9d4b6eec865811abbba0f80403914fbcac556ad3949a395ff0b43e16e6663c652959cc950fcb89d64be427eb6e8c20b19ed0aa6b1771012e7b0
7
+ data.tar.gz: d1a69946471e0e2f5bd7067889c4f675d0ea94a198d69f9e877283fa5166664f49283a45fd52f282288b690670a6d095f562935f3503c86dce81633d03858e74
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
2
+ Version 2, December 2004
3
+
4
+ Copyright (C) 2025 alessiobussolari <alessio@cosmic.tech>
5
+
6
+ Everyone is permitted to copy and distribute verbatim or modified
7
+ copies of this license document, and changing it is allowed as long
8
+ as the name is changed.
9
+
10
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
11
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
12
+
13
+ 0. You just DO WHAT THE FUCK YOU WANT TO.
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ### Clean, powerful Service Objects for Rails
6
6
 
7
7
  [![Gem Version](https://badge.fury.io/rb/better_service.svg)](https://badge.fury.io/rb/better_service)
8
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
8
+ [![License](https://img.shields.io/badge/license-WTFPL-blue.svg)](http://www.wtfpl.net/about/)
9
9
 
10
10
  [Features](#-features) • [Installation](#-installation) • [Quick Start](#-quick-start) • [Documentation](#-documentation) • [Usage](#-usage) • [Error Handling](#%EF%B8%8F-error-handling) • [Examples](#-examples)
11
11
 
@@ -28,6 +28,7 @@ BetterService is a comprehensive Service Objects framework for Rails that brings
28
28
  - 🎨 **Presenter System**: Optional data transformation layer with `BetterService::Presenter` base class
29
29
  - 📊 **Metadata Tracking**: Automatic action metadata in all service responses
30
30
  - 🔗 **Workflow Composition**: Chain multiple services into pipelines with conditional steps, rollback support, and lifecycle hooks
31
+ - 🌲 **Conditional Branching** (v1.1.0+): Multi-path workflow execution with `branch`/`on`/`otherwise` DSL for clean conditional logic
31
32
  - 🏗️ **Powerful Generators**: 10 generators for rapid scaffolding (scaffold, CRUD services, action, workflow, locale, presenter)
32
33
  - 📦 **6 Service Types**: Specialized services for different use cases
33
34
  - 🎨 **DSL-Based**: Clean, expressive DSL with `search_with`, `process_with`, `authorize_with`, etc.
@@ -1545,7 +1546,7 @@ See [Configuration Guide](docs/start/configuration.md) for more details.
1545
1546
 
1546
1547
  ## 📄 License
1547
1548
 
1548
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
1549
+ The gem is available as open source under the terms of the [WTFPL License](http://www.wtfpl.net/about/).
1549
1550
 
1550
1551
  ---
1551
1552
 
data/Rakefile CHANGED
@@ -2,8 +2,198 @@ require "bundler/setup"
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/testtask"
5
+ require "fileutils"
5
6
 
6
- Rake::TestTask.new(:test) do |t|
7
+ # Track files created during test setup
8
+ CREATED_FILES_MARKER = ".test_created_files"
9
+ PRODUCT_SERVICES_DIR = "test/dummy/app/services/product"
10
+
11
+ # Service file templates
12
+ SERVICE_TEMPLATES = {
13
+ "create_service.rb" => <<~RUBY,
14
+ # frozen_string_literal: true
15
+
16
+ class Product::CreateService < BetterService::Services::CreateService
17
+ # Schema for validating params
18
+ schema do
19
+ required(:name).filled(:string)
20
+ required(:price).filled(:decimal, gt?: 0)
21
+ optional(:published).filled(:bool)
22
+ end
23
+
24
+ # Phase 1: Search - Prepare dependencies (optional)
25
+ search_with do
26
+ {}
27
+ end
28
+
29
+ # Phase 2: Process - Create the resource
30
+ process_with do |data|
31
+ product = user.products.create!(params)
32
+ { resource: product }
33
+ end
34
+
35
+ # Phase 4: Respond - Format response (optional override)
36
+ respond_with do |data|
37
+ success_result("Product created successfully", data)
38
+ end
39
+ end
40
+ RUBY
41
+ "index_service.rb" => <<~RUBY,
42
+ # frozen_string_literal: true
43
+
44
+ class Product::IndexService < BetterService::Services::IndexService
45
+ # Schema for validating params
46
+ schema do
47
+ optional(:page).filled(:integer, gteq?: 1)
48
+ optional(:per_page).filled(:integer, gteq?: 1, lteq?: 100)
49
+ optional(:search).maybe(:string)
50
+ end
51
+
52
+ # Phase 1: Search - Load raw data
53
+ search_with do
54
+ products = user.products
55
+ products = products.where("name LIKE ?", "%\#{params[:search]}%") if params[:search].present?
56
+
57
+ { items: products.to_a }
58
+ end
59
+
60
+ # Phase 2: Process - Transform and aggregate data
61
+ process_with do |data|
62
+ {
63
+ items: data[:items],
64
+ metadata: {
65
+ stats: {
66
+ total: data[:items].count
67
+ },
68
+ pagination: {
69
+ page: params[:page] || 1,
70
+ per_page: params[:per_page] || 25
71
+ }
72
+ }
73
+ }
74
+ end
75
+
76
+ # Phase 4: Respond - Format response (optional override)
77
+ respond_with do |data|
78
+ success_result("Products loaded successfully", data)
79
+ end
80
+ end
81
+ RUBY
82
+ "show_service.rb" => <<~RUBY,
83
+ # frozen_string_literal: true
84
+
85
+ class Product::ShowService < BetterService::Services::ShowService
86
+ # Schema for validating params
87
+ schema do
88
+ required(:id).filled
89
+ end
90
+
91
+ # Phase 1: Search - Load the resource
92
+ search_with do
93
+ { resource: user.products.find(params[:id]) }
94
+ end
95
+
96
+ # Phase 4: Respond - Format response (optional override)
97
+ respond_with do |data|
98
+ success_result("Product loaded successfully", data)
99
+ end
100
+ end
101
+ RUBY
102
+ "update_service.rb" => <<~RUBY,
103
+ # frozen_string_literal: true
104
+
105
+ class Product::UpdateService < BetterService::Services::UpdateService
106
+ # Schema for validating params
107
+ schema do
108
+ required(:id).filled
109
+ end
110
+
111
+ # Phase 1: Search - Load the resource
112
+ search_with do
113
+ { resource: user.products.find(params[:id]) }
114
+ end
115
+
116
+ # Phase 2: Process - Update the resource
117
+ process_with do |data|
118
+ product = data[:resource]
119
+ product.update!(params.except(:id))
120
+ { resource: product }
121
+ end
122
+
123
+ # Phase 4: Respond - Format response (optional override)
124
+ respond_with do |data|
125
+ success_result("Product updated successfully", data)
126
+ end
127
+ end
128
+ RUBY
129
+ "destroy_service.rb" => <<~RUBY
130
+ # frozen_string_literal: true
131
+
132
+ class Product::DestroyService < BetterService::Services::DestroyService
133
+ # Schema for validating params
134
+ schema do
135
+ required(:id).filled
136
+ end
137
+
138
+ # Phase 1: Search - Load the resource
139
+ search_with do
140
+ { resource: user.products.find(params[:id]) }
141
+ end
142
+
143
+ # Phase 2: Process - Delete the resource
144
+ process_with do |data|
145
+ product = data[:resource]
146
+ product.destroy!
147
+ { resource: product }
148
+ end
149
+
150
+ # Phase 4: Respond - Format response (optional override)
151
+ respond_with do |data|
152
+ success_result("Product deleted successfully", data)
153
+ end
154
+ end
155
+ RUBY
156
+ }
157
+
158
+ namespace :test do
159
+ desc "Setup test environment - create missing Product service files"
160
+ task :setup do
161
+ created_files = []
162
+
163
+ SERVICE_TEMPLATES.each do |filename, content|
164
+ filepath = File.join(PRODUCT_SERVICES_DIR, filename)
165
+
166
+ unless File.exist?(filepath)
167
+ puts "Creating temporary test file: #{filepath}"
168
+ File.write(filepath, content)
169
+ created_files << filepath
170
+ end
171
+ end
172
+
173
+ # Save list of created files
174
+ File.write(CREATED_FILES_MARKER, created_files.join("\n")) if created_files.any?
175
+ puts "Test setup complete (#{created_files.size} files created)" if created_files.any?
176
+ end
177
+
178
+ desc "Cleanup test environment - remove temporary Product service files"
179
+ task :cleanup do
180
+ if File.exist?(CREATED_FILES_MARKER)
181
+ created_files = File.read(CREATED_FILES_MARKER).split("\n")
182
+
183
+ created_files.each do |filepath|
184
+ if File.exist?(filepath)
185
+ puts "Removing temporary test file: #{filepath}"
186
+ File.delete(filepath)
187
+ end
188
+ end
189
+
190
+ File.delete(CREATED_FILES_MARKER)
191
+ puts "Test cleanup complete (#{created_files.size} files removed)" if created_files.any?
192
+ end
193
+ end
194
+ end
195
+
196
+ Rake::TestTask.new(:test_only) do |t|
7
197
  t.libs << "test"
8
198
  t.test_files = FileList["test/**/*_test.rb"].exclude(
9
199
  "test/dummy/**/*",
@@ -12,4 +202,14 @@ Rake::TestTask.new(:test) do |t|
12
202
  t.verbose = false
13
203
  end
14
204
 
205
+ # Main test task with automatic setup and cleanup
206
+ task :test do
207
+ begin
208
+ Rake::Task["test:setup"].invoke
209
+ Rake::Task["test_only"].invoke
210
+ ensure
211
+ Rake::Task["test:cleanup"].invoke
212
+ end
213
+ end
214
+
15
215
  task default: :test
@@ -1,3 +1,3 @@
1
1
  module BetterService
2
- VERSION = "1.0.1"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -66,6 +66,7 @@ module BetterService
66
66
  @params = params
67
67
  @context = Workflowable::Context.new(user, **params)
68
68
  @executed_steps = []
69
+ @branch_decisions = []
69
70
  @start_time = nil
70
71
  @end_time = nil
71
72
  end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ module Workflows
5
+ # Represents a single conditional branch within a workflow
6
+ #
7
+ # A Branch contains:
8
+ # - A condition (Proc) that determines if the branch should execute
9
+ # - An array of steps to execute if the condition is true
10
+ # - An optional name for identification
11
+ #
12
+ # @example
13
+ # branch = Branch.new(
14
+ # condition: ->(ctx) { ctx.user.premium? },
15
+ # name: :premium_path
16
+ # )
17
+ # branch.add_step(step1)
18
+ # branch.add_step(step2)
19
+ #
20
+ # if branch.matches?(context)
21
+ # branch.execute(context, user, params)
22
+ # end
23
+ class Branch
24
+ attr_reader :condition, :steps, :name
25
+
26
+ # Creates a new Branch
27
+ #
28
+ # @param condition [Proc, nil] The condition to evaluate (nil for default/otherwise branch)
29
+ # @param name [Symbol, nil] Optional name for the branch
30
+ def initialize(condition: nil, name: nil)
31
+ @condition = condition
32
+ @steps = []
33
+ @name = name
34
+ end
35
+
36
+ # Checks if this branch's condition matches the given context
37
+ #
38
+ # @param context [Workflowable::Context] The workflow context
39
+ # @return [Boolean] true if condition matches or is nil (default branch)
40
+ def matches?(context)
41
+ return true if condition.nil? # Default branch always matches
42
+
43
+ if condition.is_a?(Proc)
44
+ context.instance_exec(context, &condition)
45
+ else
46
+ condition.call(context)
47
+ end
48
+ rescue StandardError => e
49
+ Rails.logger.error "Branch condition evaluation failed: #{e.message}"
50
+ false
51
+ end
52
+
53
+ # Adds a step to this branch
54
+ #
55
+ # @param step [Workflowable::Step] The step to add
56
+ # @return [Array] The updated steps array
57
+ def add_step(step)
58
+ @steps << step
59
+ end
60
+
61
+ # Executes all steps in this branch
62
+ #
63
+ # @param context [Workflowable::Context] The workflow context
64
+ # @param user [Object] The current user
65
+ # @param params [Hash] The workflow parameters
66
+ # @param branch_decisions [Array, nil] Array to track branch decisions for nested branches
67
+ # @return [Array<Workflowable::Step>] Array of successfully executed steps
68
+ # @raise [Errors::Workflowable::Runtime::StepExecutionError] If a required step fails
69
+ def execute(context, user, params, branch_decisions = nil)
70
+ executed_steps = []
71
+
72
+ @steps.each do |step_or_branch_group|
73
+ # Handle nested BranchGroup
74
+ if step_or_branch_group.is_a?(BranchGroup)
75
+ branch_result = step_or_branch_group.call(context, user, params)
76
+
77
+ # Add executed steps from nested branch
78
+ if branch_result[:executed_steps]
79
+ executed_steps.concat(branch_result[:executed_steps])
80
+ end
81
+
82
+ # Track nested branch decisions
83
+ if branch_decisions && branch_result[:branch_decisions]
84
+ branch_decisions.concat(branch_result[:branch_decisions])
85
+ end
86
+
87
+ next
88
+ end
89
+
90
+ # Handle regular Step
91
+ result = step_or_branch_group.call(context, user, params)
92
+
93
+ # Skip if step was skipped
94
+ next if result[:skipped]
95
+
96
+ # Handle step failure
97
+ if result[:success] == false && !result[:optional_failure]
98
+ raise Errors::Workflowable::Runtime::StepExecutionError.new(
99
+ "Step #{step_or_branch_group.name} failed in branch",
100
+ code: ErrorCodes::STEP_EXECUTION_FAILED,
101
+ context: {
102
+ step: step_or_branch_group.name,
103
+ branch: @name,
104
+ errors: result[:errors]
105
+ }
106
+ )
107
+ end
108
+
109
+ # Track successfully executed steps
110
+ executed_steps << step_or_branch_group unless result[:optional_failure]
111
+ end
112
+
113
+ executed_steps
114
+ end
115
+
116
+ # Returns whether this is a default branch (no condition)
117
+ #
118
+ # @return [Boolean]
119
+ def default?
120
+ condition.nil?
121
+ end
122
+
123
+ # Returns a string representation of this branch
124
+ #
125
+ # @return [String]
126
+ def inspect
127
+ "#<BetterService::Workflows::Branch name=#{@name.inspect} " \
128
+ "condition=#{@condition.present? ? 'present' : 'nil'} " \
129
+ "steps=#{@steps.count}>"
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ module Workflows
5
+ # DSL context for defining conditional branches
6
+ #
7
+ # This class provides the DSL methods available inside a `branch do...end` block:
8
+ # - `on(condition, &block)` - Defines a conditional branch
9
+ # - `otherwise(&block)` - Defines the default branch
10
+ # - `step(...)` - Adds a step to the current branch
11
+ # - `branch(&block)` - Allows nested branch groups
12
+ #
13
+ # @example
14
+ # branch do
15
+ # on ->(ctx) { ctx.user.premium? } do
16
+ # step :premium_action, with: PremiumService
17
+ # end
18
+ #
19
+ # on ->(ctx) { ctx.user.basic? } do
20
+ # step :basic_action, with: BasicService
21
+ # end
22
+ #
23
+ # otherwise do
24
+ # step :default_action, with: DefaultService
25
+ # end
26
+ # end
27
+ class BranchDSL
28
+ attr_reader :branch_group
29
+
30
+ # Creates a new BranchDSL context
31
+ #
32
+ # @param branch_group [BranchGroup] The branch group being configured
33
+ def initialize(branch_group)
34
+ @branch_group = branch_group
35
+ @current_branch = nil
36
+ @branch_index = 0
37
+ end
38
+
39
+ # Defines a conditional branch
40
+ #
41
+ # @param condition [Proc] The condition to evaluate (receives context)
42
+ # @param block [Proc] The block defining steps for this branch
43
+ # @return [void]
44
+ #
45
+ # @example
46
+ # on ->(ctx) { ctx.user.premium? } do
47
+ # step :send_premium_email, with: Email::PremiumService
48
+ # end
49
+ def on(condition, &block)
50
+ raise ArgumentError, "Condition must be a Proc" unless condition.is_a?(Proc)
51
+ raise ArgumentError, "Block required for 'on'" unless block_given?
52
+
53
+ @branch_index += 1
54
+ branch_name = :"on_#{@branch_index}"
55
+
56
+ @current_branch = @branch_group.add_branch(
57
+ condition: condition,
58
+ name: branch_name
59
+ )
60
+
61
+ # Evaluate the block in this DSL context
62
+ instance_eval(&block)
63
+
64
+ @current_branch = nil
65
+ end
66
+
67
+ # Defines the default branch (executed when no condition matches)
68
+ #
69
+ # @param block [Proc] The block defining steps for the default branch
70
+ # @return [void]
71
+ #
72
+ # @example
73
+ # otherwise do
74
+ # step :default_action, with: DefaultService
75
+ # end
76
+ def otherwise(&block)
77
+ raise ArgumentError, "Block required for 'otherwise'" unless block_given?
78
+ raise ArgumentError, "Default branch already defined" if @branch_group.default_branch
79
+
80
+ @current_branch = @branch_group.set_default
81
+
82
+ # Evaluate the block in this DSL context
83
+ instance_eval(&block)
84
+
85
+ @current_branch = nil
86
+ end
87
+
88
+ # Adds a step to the current branch
89
+ #
90
+ # This method has the same signature as the workflow-level `step` method.
91
+ #
92
+ # @param name [Symbol] The step name
93
+ # @param with [Class] The service class to execute
94
+ # @param input [Proc, nil] Optional input mapper
95
+ # @param optional [Boolean] Whether the step is optional
96
+ # @param if [Proc, nil] Optional condition for step execution
97
+ # @param rollback [Proc, nil] Optional rollback handler
98
+ # @return [void]
99
+ def step(name, with:, input: nil, optional: false, **options)
100
+ raise "step must be called within an 'on' or 'otherwise' block" unless @current_branch
101
+
102
+ # Handle Ruby keyword 'if' gracefully
103
+ condition = options[:if] || options.dig(:binding)&.local_variable_get(:if) rescue nil
104
+
105
+ step_obj = Workflowable::Step.new(
106
+ name: name,
107
+ service_class: with,
108
+ input: input,
109
+ optional: optional,
110
+ condition: condition,
111
+ rollback: options[:rollback]
112
+ )
113
+
114
+ @current_branch.add_step(step_obj)
115
+ end
116
+
117
+ # Allows nested branch groups within branches
118
+ #
119
+ # @param block [Proc] The block defining the nested branch group
120
+ # @return [void]
121
+ #
122
+ # @example
123
+ # on ->(ctx) { ctx.type == 'contract' } do
124
+ # step :validate_contract, with: ValidateService
125
+ #
126
+ # branch do
127
+ # on ->(ctx) { ctx.value > 10000 } do
128
+ # step :ceo_approval, with: CEOApprovalService
129
+ # end
130
+ # otherwise do
131
+ # step :manager_approval, with: ManagerApprovalService
132
+ # end
133
+ # end
134
+ # end
135
+ def branch(&block)
136
+ raise "branch must be called within an 'on' or 'otherwise' block" unless @current_branch
137
+ raise ArgumentError, "Block required for 'branch'" unless block_given?
138
+
139
+ # Create nested branch group
140
+ nested_group = BranchGroup.new(name: :"nested_branch_#{@branch_index}")
141
+ nested_dsl = BranchDSL.new(nested_group)
142
+
143
+ # Evaluate the block in the nested DSL context
144
+ nested_dsl.instance_eval(&block)
145
+
146
+ # Add the branch group as a "step" (it responds to call like a step)
147
+ @current_branch.add_step(nested_group)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ module Workflows
5
+ # Represents a group of conditional branches in a workflow
6
+ #
7
+ # A BranchGroup is created by the `branch do...end` DSL block and contains:
8
+ # - Multiple conditional branches (from `on` blocks)
9
+ # - An optional default branch (from `otherwise` block)
10
+ #
11
+ # When executed, it evaluates conditions in order and executes the first matching branch.
12
+ # If no branch matches and there's no default, it raises an error.
13
+ #
14
+ # @example
15
+ # branch_group = BranchGroup.new(name: :payment_routing)
16
+ # branch_group.add_branch(condition: ->(ctx) { ctx.type == 'card' }) do
17
+ # # steps...
18
+ # end
19
+ # branch_group.set_default do
20
+ # # default steps...
21
+ # end
22
+ #
23
+ # result = branch_group.call(context, user, params)
24
+ class BranchGroup
25
+ attr_reader :branches, :default_branch, :name
26
+
27
+ # Creates a new BranchGroup
28
+ #
29
+ # @param name [Symbol, nil] Optional name for the branch group
30
+ def initialize(name: nil)
31
+ @branches = []
32
+ @default_branch = nil
33
+ @name = name
34
+ end
35
+
36
+ # Adds a conditional branch to this group
37
+ #
38
+ # @param condition [Proc] The condition to evaluate
39
+ # @param name [Symbol, nil] Optional name for the branch
40
+ # @return [Branch] The created branch
41
+ def add_branch(condition:, name: nil)
42
+ branch = Branch.new(condition: condition, name: name)
43
+ @branches << branch
44
+ branch
45
+ end
46
+
47
+ # Sets the default branch (otherwise)
48
+ #
49
+ # @param name [Symbol, nil] Optional name for the default branch
50
+ # @return [Branch] The created default branch
51
+ def set_default(name: nil)
52
+ @default_branch = Branch.new(condition: nil, name: name || :otherwise)
53
+ end
54
+
55
+ # Selects the first branch that matches the context
56
+ #
57
+ # @param context [Workflowable::Context] The workflow context
58
+ # @return [Branch, nil] The matching branch or nil if none match
59
+ def select_branch(context)
60
+ # Try conditional branches first (in order)
61
+ @branches.each do |branch|
62
+ return branch if branch.matches?(context)
63
+ end
64
+
65
+ # Fall back to default branch if present
66
+ @default_branch
67
+ end
68
+
69
+ # Executes the appropriate branch based on context
70
+ #
71
+ # This is called during workflow execution. It:
72
+ # 1. Selects the matching branch
73
+ # 2. Executes all steps in that branch
74
+ # 3. Returns metadata about the execution
75
+ #
76
+ # @param context [Workflowable::Context] The workflow context
77
+ # @param user [Object] The current user
78
+ # @param params [Hash] The workflow parameters
79
+ # @param parent_decisions [Array, nil] Parent branch decisions for nested tracking
80
+ # @return [Hash] Execution result with :executed_steps, :branch_taken, :branch_decisions, :skipped
81
+ # @raise [Errors::Configuration::InvalidConfigurationError] If no branch matches
82
+ def call(context, user, params, parent_decisions = nil)
83
+ selected_branch = select_branch(context)
84
+
85
+ if selected_branch.nil?
86
+ raise Errors::Configuration::InvalidConfigurationError.new(
87
+ "No matching branch found and no default branch defined",
88
+ code: ErrorCodes::CONFIGURATION_ERROR,
89
+ context: {
90
+ branch_group: @name,
91
+ branches_count: @branches.count,
92
+ has_default: !@default_branch.nil?
93
+ }
94
+ )
95
+ end
96
+
97
+ # Track this branch decision
98
+ branch_decision = "#{@name}:#{selected_branch.name}"
99
+ branch_decisions = [branch_decision]
100
+
101
+ # Execute the selected branch
102
+ executed_steps = selected_branch.execute(context, user, params, branch_decisions)
103
+
104
+ # Return execution metadata
105
+ {
106
+ executed_steps: executed_steps,
107
+ branch_taken: selected_branch,
108
+ branch_decisions: branch_decisions,
109
+ skipped: false
110
+ }
111
+ end
112
+
113
+ # Returns the total number of branches (including default)
114
+ #
115
+ # @return [Integer]
116
+ def branch_count
117
+ count = @branches.count
118
+ count += 1 if @default_branch
119
+ count
120
+ end
121
+
122
+ # Returns whether this group has a default branch
123
+ #
124
+ # @return [Boolean]
125
+ def has_default?
126
+ !@default_branch.nil?
127
+ end
128
+
129
+ # Returns a string representation of this branch group
130
+ #
131
+ # @return [String]
132
+ def inspect
133
+ "#<BetterService::Workflows::BranchGroup name=#{@name.inspect} " \
134
+ "branches=#{@branches.count} " \
135
+ "has_default=#{has_default?}>"
136
+ end
137
+ end
138
+ end
139
+ end
@@ -42,6 +42,52 @@ module BetterService
42
42
  self._steps += [ step ]
43
43
  end
44
44
 
45
+ # DSL method to define conditional branches in the workflow
46
+ #
47
+ # Creates a branch group that allows multiple conditional execution paths.
48
+ # Only one branch will be executed based on the first matching condition.
49
+ #
50
+ # @param block [Proc] Block containing branch definitions (on/otherwise)
51
+ #
52
+ # @example Define conditional branches
53
+ # branch do
54
+ # on ->(ctx) { ctx.user.premium? } do
55
+ # step :premium_feature, with: PremiumService
56
+ # end
57
+ #
58
+ # on ->(ctx) { ctx.user.basic? } do
59
+ # step :basic_feature, with: BasicService
60
+ # end
61
+ #
62
+ # otherwise do
63
+ # step :default_feature, with: DefaultService
64
+ # end
65
+ # end
66
+ def branch(&block)
67
+ raise ArgumentError, "Block required for 'branch'" unless block_given?
68
+
69
+ # Count existing branch groups to determine index
70
+ branch_count = _steps.count { |s| s.is_a?(BranchGroup) }
71
+
72
+ # Create branch group
73
+ branch_group = BranchGroup.new(name: :"branch_#{branch_count + 1}")
74
+
75
+ # Create DSL context and evaluate block
76
+ branch_dsl = BranchDSL.new(branch_group)
77
+ branch_dsl.instance_eval(&block)
78
+
79
+ # Validate: must have at least one branch (conditional or default)
80
+ if branch_group.branches.empty? && branch_group.default_branch.nil?
81
+ raise Errors::Configuration::InvalidConfigurationError.new(
82
+ "Branch group must contain at least one 'on' or 'otherwise' block",
83
+ code: ErrorCodes::CONFIGURATION_ERROR
84
+ )
85
+ end
86
+
87
+ # Add branch group to steps
88
+ self._steps += [ branch_group ]
89
+ end
90
+
45
91
  # Enable or disable database transactions for the entire workflow
46
92
  #
47
93
  # @param enabled [Boolean] Whether to use transactions
@@ -22,16 +22,39 @@ module BetterService
22
22
  steps_executed = []
23
23
  steps_skipped = []
24
24
 
25
- self.class._steps.each do |step|
26
- # Execute step with around_step callbacks
25
+ self.class._steps.each do |step_or_branch|
26
+ # Handle BranchGroup (conditional branching)
27
+ if step_or_branch.is_a?(BranchGroup)
28
+ branch_result = nil
29
+ run_around_step_callbacks(step_or_branch, @context) do
30
+ branch_result = step_or_branch.call(@context, @user, @params)
31
+ end
32
+
33
+ # Track branch decisions (including nested)
34
+ if branch_result[:branch_decisions]
35
+ @branch_decisions.concat(branch_result[:branch_decisions])
36
+ end
37
+
38
+ # Track executed steps from the branch
39
+ if branch_result[:executed_steps]
40
+ branch_result[:executed_steps].each do |executed_step|
41
+ @executed_steps << executed_step
42
+ steps_executed << executed_step.name
43
+ end
44
+ end
45
+
46
+ next
47
+ end
48
+
49
+ # Handle regular Step
27
50
  result = nil
28
- run_around_step_callbacks(step, @context) do
29
- result = step.call(@context, @user, @params)
51
+ run_around_step_callbacks(step_or_branch, @context) do
52
+ result = step_or_branch.call(@context, @user, @params)
30
53
  end
31
54
 
32
55
  # Track skipped steps
33
56
  if result[:skipped]
34
- steps_skipped << step.name
57
+ steps_skipped << step_or_branch.name
35
58
  next
36
59
  end
37
60
 
@@ -41,11 +64,11 @@ module BetterService
41
64
  rollback_steps
42
65
 
43
66
  raise Errors::Workflowable::Runtime::StepExecutionError.new(
44
- "Step #{step.name} failed: #{result[:error] || result[:message]}",
67
+ "Step #{step_or_branch.name} failed: #{result[:error] || result[:message]}",
45
68
  code: ErrorCodes::STEP_FAILED,
46
69
  context: {
47
70
  workflow: self.class.name,
48
- step: step.name,
71
+ step: step_or_branch.name,
49
72
  steps_executed: steps_executed,
50
73
  errors: result[:errors] || {}
51
74
  }
@@ -53,8 +76,8 @@ module BetterService
53
76
  end
54
77
 
55
78
  # Track successful execution
56
- @executed_steps << step
57
- steps_executed << step.name
79
+ @executed_steps << step_or_branch
80
+ steps_executed << step_or_branch.name
58
81
  end
59
82
 
60
83
  # All steps succeeded
@@ -66,6 +89,9 @@ module BetterService
66
89
  rescue Errors::Workflowable::Runtime::StepExecutionError
67
90
  # Step error already raised, just re-raise
68
91
  raise
92
+ rescue Errors::Configuration::InvalidConfigurationError
93
+ # Configuration error (e.g., no matching branch), just re-raise
94
+ raise
69
95
  rescue StandardError => e
70
96
  # Unexpected error during workflow execution
71
97
  rollback_steps
@@ -15,16 +15,21 @@ module BetterService
15
15
  # @param steps_skipped [Array<Symbol>] Names of steps that were skipped
16
16
  # @return [Hash] Success result with context and metadata
17
17
  def build_success_result(steps_executed: [], steps_skipped: [])
18
+ metadata = {
19
+ workflow: self.class.name,
20
+ steps_executed: steps_executed,
21
+ steps_skipped: steps_skipped,
22
+ duration_ms: duration_ms
23
+ }
24
+
25
+ # Include branch decisions if any branches were taken
26
+ metadata[:branches_taken] = @branch_decisions if @branch_decisions.any?
27
+
18
28
  {
19
29
  success: true,
20
30
  message: "Workflow completed successfully",
21
31
  context: @context,
22
- metadata: {
23
- workflow: self.class.name,
24
- steps_executed: steps_executed,
25
- steps_skipped: steps_skipped,
26
- duration_ms: duration_ms
27
- }
32
+ metadata: metadata
28
33
  }
29
34
  end
30
35
 
@@ -37,22 +42,26 @@ module BetterService
37
42
  # @param steps_skipped [Array<Symbol>] Names of steps that were skipped
38
43
  # @return [Hash] Failure result with error details and metadata
39
44
  def build_failure_result(message: nil, errors: {}, failed_step: nil, steps_executed: [], steps_skipped: [])
40
- result = {
45
+ metadata = {
46
+ workflow: self.class.name,
47
+ failed_step: failed_step,
48
+ steps_executed: steps_executed,
49
+ steps_skipped: steps_skipped,
50
+ duration_ms: duration_ms
51
+ }
52
+
53
+ # Include branch decisions if any branches were taken before failure
54
+ metadata[:branches_taken] = @branch_decisions if @branch_decisions.any?
55
+
56
+ metadata.delete(:failed_step) if failed_step.nil?
57
+
58
+ {
41
59
  success: false,
42
60
  error: message || @context.errors[:message] || "Workflow failed",
43
61
  errors: errors.any? ? errors : @context.errors,
44
62
  context: @context,
45
- metadata: {
46
- workflow: self.class.name,
47
- failed_step: failed_step,
48
- steps_executed: steps_executed,
49
- steps_skipped: steps_skipped,
50
- duration_ms: duration_ms
51
- }
63
+ metadata: metadata
52
64
  }
53
-
54
- result[:metadata].delete(:failed_step) if failed_step.nil?
55
- result
56
65
  end
57
66
 
58
67
  # Calculate duration in milliseconds
@@ -20,6 +20,9 @@ require "better_service/concerns/workflowable/callbacks"
20
20
  require "better_service/workflows/result_builder"
21
21
  require "better_service/workflows/rollback_support"
22
22
  require "better_service/workflows/transaction_support"
23
+ require "better_service/workflows/branch"
24
+ require "better_service/workflows/branch_group"
25
+ require "better_service/workflows/branch_dsl"
23
26
  require "better_service/workflows/dsl"
24
27
  require "better_service/workflows/execution"
25
28
  require "better_service/workflows/base"
@@ -48,6 +48,28 @@ class <%= workflow_class_name %> < BetterService::Workflow
48
48
  # input: ->(ctx) { { resource_id: ctx.resource.id } },
49
49
  # optional: true,
50
50
  # if: ->(ctx) { ctx.user.notifications_enabled? }
51
+ #
52
+ # Conditional branching (NEW):
53
+ #
54
+ # branch do
55
+ # on ->(ctx) { ctx.user.premium? } do
56
+ # step :premium_feature,
57
+ # with: <%= class_name %>::PremiumService,
58
+ # input: ->(ctx) { { resource: ctx.resource } }
59
+ # end
60
+ #
61
+ # on ->(ctx) { ctx.user.basic? } do
62
+ # step :basic_feature,
63
+ # with: <%= class_name %>::BasicService,
64
+ # input: ->(ctx) { { resource: ctx.resource } }
65
+ # end
66
+ #
67
+ # otherwise do
68
+ # step :default_feature,
69
+ # with: <%= class_name %>::DefaultService,
70
+ # input: ->(ctx) { { resource: ctx.resource } }
71
+ # end
72
+ # end
51
73
 
52
74
  <% end -%>
53
75
  private
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_service
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - alessiobussolari
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-11 00:00:00.000000000 Z
11
+ date: 2025-11-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -74,7 +74,7 @@ executables: []
74
74
  extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
- - MIT-LICENSE
77
+ - LICENSE
78
78
  - README.md
79
79
  - Rakefile
80
80
  - config/locales/better_service.en.yml
@@ -128,6 +128,9 @@ files:
128
128
  - lib/better_service/subscribers/stats_subscriber.rb
129
129
  - lib/better_service/version.rb
130
130
  - lib/better_service/workflows/base.rb
131
+ - lib/better_service/workflows/branch.rb
132
+ - lib/better_service/workflows/branch_dsl.rb
133
+ - lib/better_service/workflows/branch_group.rb
131
134
  - lib/better_service/workflows/dsl.rb
132
135
  - lib/better_service/workflows/execution.rb
133
136
  - lib/better_service/workflows/result_builder.rb
@@ -161,7 +164,7 @@ files:
161
164
  - lib/tasks/better_service_tasks.rake
162
165
  homepage: https://github.com/alessiobussolari/better_service
163
166
  licenses:
164
- - MIT
167
+ - WTFPL
165
168
  metadata:
166
169
  homepage_uri: https://github.com/alessiobussolari/better_service
167
170
  source_code_uri: https://github.com/alessiobussolari/better_service
data/MIT-LICENSE DELETED
@@ -1,20 +0,0 @@
1
- Copyright alessiobussolari
2
-
3
- Permission is hereby granted, free of charge, to any person obtaining
4
- a copy of this software and associated documentation files (the
5
- "Software"), to deal in the Software without restriction, including
6
- without limitation the rights to use, copy, modify, merge, publish,
7
- distribute, sublicense, and/or sell copies of the Software, and to
8
- permit persons to whom the Software is furnished to do so, subject to
9
- the following conditions:
10
-
11
- The above copyright notice and this permission notice shall be
12
- included in all copies or substantial portions of the Software.
13
-
14
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
- LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
- OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
- WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.