hati-operation 0.1.0rc1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 72dc2705859bb9f1adb733a79d22a26d76c0fed1c7b95463c4a3d4b32622b620
4
+ data.tar.gz: cdc0f2d92c11b1c7f3558aa0c709b1e280fa95a1e7f7a4860620d26a49cf0281
5
+ SHA512:
6
+ metadata.gz: 7ee02c538a323c57d75a4641115acd3096c61ebad6ba46f99feb5d22dd1c1db725909f8c0a8edb885eb5ab3fd80be852e8f0abf2a3d7ba5e098967bf649fe5fc
7
+ data.tar.gz: 9ec5d460edf8364eb1b67bc63da87fbf93e9ab1b0d23e2f432751ab6f97af15db38f05152eb1126b6ae533043116f4928277661e66bb93743abdf5ef90e091f9
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 hackico.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # HatiOperation
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/hati_operation.svg)](https://rubygems.org/gems/hati_operation)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](#license)
5
+
6
+ HatiOperation is a lightweight Ruby toolkit that helps you compose domain logic into clear, reusable **operations**. Built on top of [hati-command](https://github.com/hackico-ai/ruby-hati-command), it serves as an **aggregator** that orchestrates multiple services and commands into cohesive business operations.
7
+
8
+ ## ✨ Key Features
9
+
10
+ - **Step-based execution** – write each unit of work as a small service object and compose them with `step`
11
+ - **Implicit result propagation** – methods return `Success(...)` or `Failure(...)` and are automatically unpacked
12
+ - **Fail-fast transactions** – stop the chain as soon as a step fails
13
+ - **Dependency injection (DI)** – override steps at call-time for ultimate flexibility
14
+ - **Macro DSL** – declaratively configure validation, error mapping, transactions and more
15
+ - **Service aggregation** – orchestrate multiple services into cohesive business operations
16
+
17
+ ## 🏗️ Architecture
18
+
19
+ HatiOperation builds on top of [hati-command](https://github.com/hackico-ai/ruby-hati-command) and serves as an **aggregator pattern** implementation:
20
+
21
+ ```
22
+ ┌─────────────────────────────────────────────────────────────┐
23
+ │ HatiOperation │
24
+ │ (Aggregator Layer) │
25
+ ├─────────────────────────────────────────────────────────────┤
26
+ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
27
+ │ │ Service A │ │ Service B │ │ Service C │ │
28
+ │ │ (Command) │ │ (Command) │ │ (Command) │ │
29
+ │ └─────────────┘ └─────────────┘ └─────────────┘ │
30
+ ├─────────────────────────────────────────────────────────────┤
31
+ │ hati-command │
32
+ │ (Foundation Layer) │
33
+ └─────────────────────────────────────────────────────────────┘
34
+ ```
35
+
36
+ ## 📋 Table of Contents
37
+
38
+ 1. [Installation](#installation)
39
+ 2. [Quick Start](#quick-start)
40
+ 3. [Step DSL](#step-dsl)
41
+ 4. [Dependency Injection](#dependency-injection)
42
+ 5. [Alternative DSL Styles](#alternative-dsl-styles)
43
+ 6. [Testing](#testing)
44
+ 7. [Contributing](#contributing)
45
+ 8. [License](#license)
46
+
47
+ ## 🚀 Installation
48
+
49
+ Add HatiOperation to your Gemfile and bundle:
50
+
51
+ ```ruby
52
+ # Gemfile
53
+ gem 'hati_operation'
54
+ ```
55
+
56
+ ```bash
57
+ bundle install
58
+ ```
59
+
60
+ Alternatively:
61
+
62
+ ```bash
63
+ gem install hati_operation
64
+ ```
65
+
66
+ ## 🎯 Quick Start
67
+
68
+ The example below shows how HatiOperation can be leveraged inside a **Rails API** controller to aggregate multiple services:
69
+
70
+ ```ruby
71
+ # app/controllers/api/v1/withdrawal_controller.rb
72
+ class Api::V1::WithdrawalController < ApplicationController
73
+ def create
74
+ result = Withdrawal::Operation::Create.call(params: params.to_unsafe_h)
75
+
76
+ run_and_render(result)
77
+ end
78
+
79
+ private
80
+
81
+ def run_and_render(result)
82
+ if result.success?
83
+ render json: TransferSerializer.new.serialize(result.value), status: :created
84
+ else
85
+ error = ApiError.new(result.value)
86
+ render json: error.to_json, status: error.status
87
+ end
88
+ end
89
+ end
90
+ ```
91
+
92
+ ### 🔧 Defining the Operation
93
+
94
+ ```ruby
95
+ # app/operations/withdrawal/operation/create.rb
96
+ class Withdrawal::Operation::Create < HatiOperation::Base
97
+ # Wrap everything in DB transaction
98
+ ar_transaction :funds_transfer_transaction!
99
+
100
+ def call(params:)
101
+ params = step MyApiContract.call(params), err: ApiErr.call(422)
102
+ transfer = step funds_transfer_transaction(params[:account_id])
103
+ EventBroadcast.new.stream(transfer.to_event)
104
+
105
+ transfer.meta
106
+ end
107
+
108
+ def funds_transfer_transaction(acc_id)
109
+ acc = Account.find_by(find_by: acc_id).presence : Failure!(err: ApiErr.call(404))
110
+
111
+ withdrawal = step WithdrawalService.call(acc), err: ApiErr.call(409)
112
+ transfer = step ProcessTransferService.call(withdrawal), err: ApiErr.call(503)
113
+
114
+ Success(transfer)
115
+ end
116
+ end
117
+ ```
118
+
119
+ ### 🎛️ Base Operation Configuration
120
+
121
+ ```ruby
122
+ class ApiOperation < HatiOperation::Base
123
+ operation do
124
+ unexpected_err ApiErr.call(500)
125
+ end
126
+ end
127
+ ```
128
+
129
+ ## 🛠️ Step DSL
130
+
131
+ The DSL gives you fine-grained control over every stage of the operation:
132
+
133
+ ### Core DSL Methods
134
+
135
+ - `step` – register a dependency service
136
+ - `params` – validate/transform incoming parameters
137
+ - `on_success` – handle successful operation results
138
+ - `on_failure` – map and handle failure results
139
+
140
+ ### Extended Configuration
141
+
142
+ > 📖 **See:** [hati-command](https://github.com/hackico-ai/ruby-hati-command) for all configuration options
143
+
144
+ - `ar_transaction` – execute inside database transaction
145
+ - `fail_fast` – configure fail-fast behavior
146
+ - `failure` – set default failure handling
147
+ - `unexpected_err` – configure generic error behavior
148
+
149
+ ## 🔄 Dependency Injection
150
+
151
+ At runtime you can swap out any step for testing, feature-flags, or different environments:
152
+
153
+ ```ruby
154
+ result = Withdrawal::Operation::Create.call(params) do
155
+ step broadcast: DummyBroadcastService
156
+ step transfer: StubbedPaymentProcessor
157
+ end
158
+ ```
159
+
160
+ ## 🎨 Alternative DSL Styles
161
+
162
+ ### Declarative Style
163
+
164
+ Prefer more declarative code? Use the class-level DSL:
165
+
166
+ ```ruby
167
+ class Withdrawal::Operation::Create < ApiOperation
168
+ params CreateContract, err: ApiErr.call(422)
169
+
170
+ ar_transaction :funds_transfer_transaction!
171
+
172
+ step withdrawal: WithdrawalService, err: ApiErr.call(409)
173
+ step transfer: ProcessTransferService, err: ApiErr.call(503)
174
+ step broadcast: Broadcast
175
+
176
+ on_success SerializerService.call(Transfer, status: 201)
177
+ on_failure ApiErrorSerializer
178
+
179
+ # requires :params keyword to access overwritten params
180
+ # same as params = step CreateContract.call(params), err: ApiErr.call(422)
181
+ def call(params:)
182
+ transfer = step funds_transfer_transaction!(params[:account_id])
183
+ broadcast.new.stream(transfer.to_event)
184
+ transfer.meta
185
+ end
186
+
187
+ def funds_transfer_transaction!(acc_id)
188
+ acc = step(err: ApiErr.call(404)) { User.find(id) }
189
+
190
+ withdrawal = step withdrawal.call(acc)
191
+ transfer = step transfer.call(withdrawal)
192
+ Success(transfer)
193
+ end
194
+ end
195
+
196
+ class Api::V2::WithdrawalController < ApiController
197
+ def create
198
+ run_and_render Withdrawal::Operation::Create
199
+ end
200
+
201
+ private
202
+
203
+ def run_and_render(operation, &block)
204
+ render JsonResult.prepare operation.call(params.to_unsafe_h).value
205
+ end
206
+ end
207
+ ```
208
+
209
+ ### 🏗️ Full-Stack DI Example
210
+
211
+ ```ruby
212
+ class Api::V2::WithdrawalController < ApplicationController
213
+ def create
214
+ run_and_render Withdrawal::Operation::Create.call(params.to_unsafe_h) do
215
+ step broadcast: API::V2::BroadcastService
216
+ step transfer: API::V2::PaymentProcessorService
217
+ step serializer: ExtendedTransferSerializer
218
+ end
219
+ end
220
+ end
221
+ ```
222
+
223
+ ## 🧪 Testing
224
+
225
+ Run the test-suite with:
226
+
227
+ ```bash
228
+ bundle exec rspec
229
+ ```
230
+
231
+ HatiOperation is fully covered by RSpec. See `spec/` for reference examples including stubbed services and DI.
232
+
233
+ ## 🤝 Contributing
234
+
235
+ Bug reports and pull requests are welcome on GitHub. Please:
236
+
237
+ 1. Fork the project and create your branch from `main`
238
+ 2. Run `bundle exec rspec` to ensure tests pass
239
+ 3. Submit a pull request with a clear description of your changes
240
+
241
+ ## 📄 License
242
+
243
+ HatiOperation is released under the MIT License.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+
6
+ require 'hati_operation/version'
7
+
8
+ Gem::Specification.new do |spec|
9
+ spec.name = 'hati-operation'
10
+ spec.version = HatiOperation::VERSION
11
+ spec.authors = ['Mariya Giy']
12
+ spec.email = %w[giy.mariya@gmail.com]
13
+ spec.license = 'MIT'
14
+
15
+ spec.summary = 'A Ruby gem for encapsulating business logic in reusable, testable operation classes.'
16
+ spec.description = 'Encapsulates business logic in isolated, reusable operation classes for clarity and testability'
17
+ spec.homepage = "https://github.com/hackico-ai/#{spec.name}"
18
+
19
+ spec.required_ruby_version = '>= 3.0.0'
20
+
21
+ spec.files = Dir['CHANGELOG.md', 'LICENSE', 'README.md', 'hati-operation.gemspec', 'lib/**/*']
22
+ spec.bindir = 'bin'
23
+ spec.executables = []
24
+ spec.require_paths = ['lib']
25
+
26
+ spec.metadata['repo_homepage'] = spec.homepage
27
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
28
+
29
+ spec.metadata['homepage_uri'] = spec.homepage
30
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
31
+ spec.metadata['source_code_uri'] = spec.homepage
32
+ spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues"
33
+
34
+ spec.metadata['rubygems_mfa_required'] = 'true'
35
+
36
+ spec.add_dependency 'hati-command'
37
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hati_command'
4
+
5
+ # Dev version Feautures
6
+ # - implicit result return
7
+ # - object lvl step for <#value> unpacking
8
+ # - forced logical transactional behavior - always fail_fast! on
9
+ # failure step unpacking
10
+ # - class lvl macro for DI:
11
+ # * step validate: Validator
12
+ # * operation for customization (alias to command)
13
+ # - always fail fast on step unpacking
14
+
15
+ module HatiOperation
16
+ class Base
17
+ include HatiCommand::Cmd
18
+
19
+ class << self
20
+ alias operation command
21
+
22
+ def operation_config
23
+ @operation_config ||= {}
24
+ end
25
+
26
+ def params(command, err: nil)
27
+ operation_config[:params] = command
28
+ operation_config[:params_err] = err
29
+ end
30
+
31
+ def on_success(command)
32
+ operation_config[:on_success] = command
33
+ end
34
+
35
+ def on_failure(command)
36
+ operation_config[:on_failure] = command
37
+ end
38
+
39
+ # TODO: validate type
40
+ def step(**kwargs)
41
+ # TODO: add specific error
42
+ raise 'Invalid Step type. Expected HatiCommand::Cmd' unless included_modules.include?(HatiCommand::Cmd)
43
+
44
+ name, command = kwargs.first
45
+
46
+ if kwargs[:err]
47
+ error_name = "#{name}_error".to_sym
48
+ operation_config[error_name] = kwargs[:err]
49
+ end
50
+
51
+ # WIP: restructure
52
+ operation_config[name] = command
53
+
54
+ define_method(name) do
55
+ configs = self.class.operation_config
56
+
57
+ step_exec_stack.append({ step: name, err: configs[error_name], done: false })
58
+
59
+ step_configs[name] || configs[name]
60
+ end
61
+ end
62
+
63
+ def call(*args, **kwargs, &block)
64
+ reciever = nil
65
+ injected_params = nil
66
+
67
+ if block_given?
68
+ reciever = new
69
+ container = StepConfigContainer.new
70
+
71
+ container.instance_eval(&block)
72
+ # WIP: work on defaults for DSL
73
+ reciever.step_configs.merge!(container.configurations)
74
+ injected_params = reciever.step_configs[:params]
75
+ end
76
+
77
+ params_modifier = injected_params || operation_config[:params]
78
+ # TODO: naming
79
+ if params_modifier
80
+ unless kwargs[:params]
81
+ raise 'If operation config :params is set, caller method must have :params keyword argument'
82
+ end
83
+
84
+ params_rez = params_modifier.call(kwargs[:params])
85
+ reciever_configs = reciever&.step_configs || {}
86
+ params_err = reciever_configs[:params_err] || operation_config[:params_err]
87
+
88
+ if params_rez.failure?
89
+ # WIP: override or nest ???
90
+ params_rez.err = params_err if params_err
91
+
92
+ return params_rez
93
+ end
94
+
95
+ kwargs[:params] = params_rez.value
96
+ end
97
+
98
+ result = super(*args, __command_reciever: reciever, **kwargs)
99
+ # Wrap for implicit
100
+ rez = result.respond_to?(:success?) ? result : HatiCommand::Success.new(result)
101
+
102
+ # TODO: extract
103
+ success_wrap = operation_config[:on_success]
104
+ failure_wrap = operation_config[:on_failure]
105
+
106
+ return success_wrap&.call(rez) if success_wrap && rez.success?
107
+ return failure_wrap&.call(rez) if failure_wrap && rez.failure?
108
+
109
+ rez
110
+ end
111
+ end
112
+
113
+ def step_configs
114
+ @step_configs ||= {}
115
+ end
116
+
117
+ # keep track of step macro calls
118
+ def step_exec_stack
119
+ @step_exec_stack ||= []
120
+ end
121
+
122
+ # unpack result
123
+ # wraps implicitly
124
+ def step(result = nil, err: nil, &block)
125
+ return __step_block_call!(err: err, &block) if block_given?
126
+
127
+ last_step = step_exec_stack.last
128
+ err ||= last_step[:err] if last_step
129
+
130
+ if result.is_a?(HatiCommand::Result)
131
+ Failure!(result, err: err || result.error) if result.failure?
132
+
133
+ step_exec_stack.last[:done] = true if last_step
134
+
135
+ return result.value
136
+ end
137
+
138
+ Failure!(result, err: err) if err && result.nil?
139
+
140
+ step_exec_stack.last[:done] = true if last_step
141
+
142
+ result
143
+ end
144
+
145
+ def __step_block_call!(err: nil)
146
+ yield
147
+ rescue StandardError => e
148
+ err ? Failure!(e, err: err) : Failure!(e)
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HatiOperation
4
+ class StepConfigContainer
5
+ def configurations
6
+ @configurations ||= {}
7
+ end
8
+
9
+ def step(**kwargs)
10
+ step_name, step_klass = kwargs.first
11
+
12
+ configurations[step_name] = step_klass
13
+ end
14
+
15
+ # WIP: so far as API adapter
16
+ def params(command = nil, err: nil)
17
+ configurations[:params] = command
18
+ configurations[:params_err] = err
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HatiOperation
4
+ VERSION = '0.1.0rc1'
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hati_operation/version'
4
+ require 'hati_operation/step_configs_container'
5
+ require 'hati_operation/base'
6
+ # errors
7
+ # require 'hati_operation/errors/base_error'
8
+ # require 'hati_operation/errors/configuration_error'
9
+ # require 'hati_operation/errors/fail_fast_error'
10
+ # require 'hati_operation/errors/transaction_error'
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hati-operation
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0rc1
5
+ platform: ruby
6
+ authors:
7
+ - Mariya Giy
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: hati-command
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Encapsulates business logic in isolated, reusable operation classes for
27
+ clarity and testability
28
+ email:
29
+ - giy.mariya@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE
35
+ - README.md
36
+ - hati-operation.gemspec
37
+ - lib/hati_operation.rb
38
+ - lib/hati_operation/base.rb
39
+ - lib/hati_operation/step_configs_container.rb
40
+ - lib/hati_operation/version.rb
41
+ homepage: https://github.com/hackico-ai/hati-operation
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ repo_homepage: https://github.com/hackico-ai/hati-operation
46
+ allowed_push_host: https://rubygems.org
47
+ homepage_uri: https://github.com/hackico-ai/hati-operation
48
+ changelog_uri: https://github.com/hackico-ai/hati-operation/blob/main/CHANGELOG.md
49
+ source_code_uri: https://github.com/hackico-ai/hati-operation
50
+ bug_tracker_uri: https://github.com/hackico-ai/hati-operation/issues
51
+ rubygems_mfa_required: 'true'
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 3.0.0
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.6.9
67
+ specification_version: 4
68
+ summary: A Ruby gem for encapsulating business logic in reusable, testable operation
69
+ classes.
70
+ test_files: []