hanikamu-operation 0.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.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,22 @@
1
+ version: "3"
2
+ networks:
3
+ docker-compose-example-tier:
4
+ driver: bridge
5
+ services:
6
+ redis:
7
+ image: redis:7-alpine
8
+ networks:
9
+ - docker-compose-example-tier
10
+ app:
11
+ build:
12
+ context: .
13
+ dockerfile: Dockerfile
14
+ environment:
15
+ HISTFILE: /app/tmp/ash_history
16
+ REDIS_URL: redis://redis:6379/15
17
+ volumes:
18
+ - .:/app
19
+ networks:
20
+ - docker-compose-example-tier
21
+ depends_on:
22
+ - redis
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanikamu
4
+ # :nodoc:
5
+ # rubocop:disable Metrics/ClassLength
6
+ class Operation < Hanikamu::Service
7
+ include ActiveModel::Validations
8
+
9
+ Error = Class.new(Hanikamu::Service::Error)
10
+
11
+ # Error classes
12
+ class FormError < Hanikamu::Service::Error
13
+ attr_reader :form
14
+
15
+ def initialize(form)
16
+ @form = form
17
+ super(form.is_a?(String) ? form : form.errors.full_messages.join(", "))
18
+ end
19
+
20
+ def errors
21
+ return @form if @form.is_a?(String)
22
+
23
+ @form.errors
24
+ end
25
+ end
26
+
27
+ class GuardError < Hanikamu::Service::Error
28
+ attr_reader :guard
29
+
30
+ def initialize(guard)
31
+ @guard = guard
32
+ super(guard.is_a?(String) ? guard : guard.errors.full_messages.join(", "))
33
+ end
34
+
35
+ def errors
36
+ return @guard if @guard.is_a?(String)
37
+
38
+ @guard.errors
39
+ end
40
+ end
41
+
42
+ class MissingBlockError < Hanikamu::Service::Error
43
+ end
44
+
45
+ class ConfigurationError < StandardError; end
46
+
47
+ # Configuration
48
+ setting :redis_client
49
+ setting :mutex_expire_milliseconds, default: 1500
50
+ setting :redlock_retry_count, default: 6
51
+ setting :redlock_retry_delay, default: 500
52
+ setting :redlock_retry_jitter, default: 50
53
+ setting :redlock_timeout, default: 0.1
54
+ setting :whitelisted_errors, default: [].freeze, constructor: ->(value) { Array(value) }
55
+
56
+ # Override configure to cascade whitelisted_errors to Hanikamu::Service
57
+ def self.configure
58
+ super do |config|
59
+ yield(config) if block_given?
60
+
61
+ # Always include Redlock::LockError alongside user-provided errors
62
+ whitelisted_errors = ([Redlock::LockError] + Array(config.whitelisted_errors)).uniq
63
+
64
+ # Set on both Operation and Service configs because:
65
+ # - Operation.config is checked when .call is invoked on Operation subclasses
66
+ # - Service.config is set for consistency when directly calling Hanikamu::Service
67
+ config.whitelisted_errors = whitelisted_errors
68
+ Hanikamu::Service.config.whitelisted_errors = whitelisted_errors
69
+ end
70
+ end
71
+
72
+ class << self
73
+ def redis_lock
74
+ @redis_lock ||= begin
75
+ unless config.redis_client
76
+ raise(
77
+ ConfigurationError,
78
+ "Hanikamu::Operation.config.redis_client is not configured. " \
79
+ "Please set it in an initializer: Hanikamu::Operation.config.redis_client = your_redis_client"
80
+ )
81
+ end
82
+
83
+ Redlock::Client.new(
84
+ [config.redis_client],
85
+ retry_count: config.redlock_retry_count,
86
+ retry_delay: config.redlock_retry_delay,
87
+ retry_jitter: config.redlock_retry_jitter,
88
+ redis_timeout: config.redlock_timeout
89
+ )
90
+ end
91
+ end
92
+
93
+ # DSL methods
94
+ def within_mutex(lock_key, expire_milliseconds: nil)
95
+ @_mutex_lock_key = lock_key
96
+ @_mutex_expire_milliseconds = expire_milliseconds || Hanikamu::Operation.config.mutex_expire_milliseconds
97
+ end
98
+
99
+ def within_transaction(klass)
100
+ @_transaction_klass = klass
101
+ end
102
+
103
+ def block(bool)
104
+ @_block = bool
105
+ end
106
+
107
+ # Define guard validations using a block
108
+ # The block is evaluated in the context of a Guard class
109
+ # rubocop:disable Metrics/MethodLength
110
+ def guard(&block)
111
+ return unless block
112
+
113
+ # Thread-safe constant definition with mutex
114
+ @guard_definition_mutex ||= Mutex.new
115
+ @guard_definition_mutex.synchronize do
116
+ # Remove existing Guard constant if it exists to support Rails reloading
117
+ remove_const(:Guard) if const_defined?(:Guard, false)
118
+
119
+ # Create a new Guard class with ActiveModel validations
120
+ guard_class = Class.new do
121
+ include ActiveModel::Validations
122
+
123
+ attr_reader :operation
124
+ alias service operation
125
+
126
+ def initialize(operation)
127
+ @operation = operation
128
+ end
129
+
130
+ # Helper to delegate methods to operation/service
131
+ def self.delegates(*methods)
132
+ methods.each do |method_name|
133
+ define_method(method_name) do
134
+ operation.public_send(method_name)
135
+ end
136
+ end
137
+ end
138
+
139
+ class_eval(&block)
140
+ end
141
+
142
+ const_set(:Guard, guard_class)
143
+ end
144
+ end
145
+ # rubocop:enable Metrics/MethodLength
146
+
147
+ attr_reader :_mutex_lock_key, :_mutex_expire_milliseconds, :_transaction_klass, :_block
148
+ end
149
+
150
+ def call!(&block)
151
+ validate_block!(&block)
152
+
153
+ within_mutex! do
154
+ validate!
155
+ guard!
156
+
157
+ within_transaction! do
158
+ block ? execute(&block) : execute
159
+ end
160
+ end
161
+ end
162
+
163
+ def validate_block!(&block)
164
+ return unless self.class._block
165
+
166
+ raise Hanikamu::Operation::MissingBlockError, "This service requires a block to be called" unless block
167
+ end
168
+
169
+ def within_mutex!(&)
170
+ return yield if _lock_key.nil?
171
+
172
+ Hanikamu::Operation.redis_lock.lock!(_lock_key, self.class._mutex_expire_milliseconds, &)
173
+ end
174
+
175
+ def _lock_key
176
+ return if self.class._mutex_lock_key.blank?
177
+
178
+ public_send(self.class._mutex_lock_key)
179
+ end
180
+
181
+ def within_transaction!(&)
182
+ return yield if transaction_class.nil?
183
+
184
+ transaction_class.transaction(&)
185
+ end
186
+
187
+ private
188
+
189
+ def transaction_class
190
+ return if self.class._transaction_klass.nil?
191
+ return ActiveRecord::Base if self.class._transaction_klass == :base
192
+
193
+ self.class._transaction_klass
194
+ end
195
+
196
+ def validate!
197
+ raise Hanikamu::Operation::FormError, self unless valid?
198
+ end
199
+
200
+ def guard!
201
+ # Check for Guard constant defined directly on this class, not inherited
202
+ return unless self.class.const_defined?(:Guard, false)
203
+
204
+ # Always create a fresh guard instance for this specific operation
205
+ # This prevents guard leakage when operations call other operations
206
+ @guard = self.class.const_get(:Guard).new(self)
207
+ raise_guard_error! unless @guard.valid?
208
+ end
209
+
210
+ def raise_guard_error!
211
+ raise Hanikamu::Operation::GuardError, @guard
212
+ end
213
+ end
214
+ # rubocop:enable Metrics/ClassLength
215
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanikamu/service"
4
+ require "active_model"
5
+ require "active_support/core_ext/object/blank"
6
+ require "redlock"
7
+ require "hanikamu/operation"
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hanikamu-operation
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nicolai Seerup
8
+ - Alejandro Jimenez
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2025-11-27 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activemodel
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '6.0'
21
+ - - "<"
22
+ - !ruby/object:Gem::Version
23
+ version: '9.0'
24
+ type: :runtime
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ - - ">="
29
+ - !ruby/object:Gem::Version
30
+ version: '6.0'
31
+ - - "<"
32
+ - !ruby/object:Gem::Version
33
+ version: '9.0'
34
+ - !ruby/object:Gem::Dependency
35
+ name: activerecord
36
+ requirement: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ - - "<"
42
+ - !ruby/object:Gem::Version
43
+ version: '9.0'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '6.0'
51
+ - - "<"
52
+ - !ruby/object:Gem::Version
53
+ version: '9.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activesupport
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '6.0'
61
+ - - "<"
62
+ - !ruby/object:Gem::Version
63
+ version: '9.0'
64
+ type: :runtime
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '6.0'
71
+ - - "<"
72
+ - !ruby/object:Gem::Version
73
+ version: '9.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: hanikamu-service
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '0.1'
81
+ type: :runtime
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '0.1'
88
+ - !ruby/object:Gem::Dependency
89
+ name: redlock
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '2.0'
95
+ type: :runtime
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '2.0'
102
+ description: Ruby gem for building robust service operations with guard validations,
103
+ distributed mutex locks via Redlock, database transactions, and comprehensive error
104
+ handling. Thread-safe and designed for production Rails applications.
105
+ email:
106
+ executables: []
107
+ extensions: []
108
+ extra_rdoc_files: []
109
+ files:
110
+ - CHANGELOG.md
111
+ - Dockerfile
112
+ - LICENSE.txt
113
+ - Makefile
114
+ - README.md
115
+ - Rakefile
116
+ - docker-compose.yml
117
+ - lib/hanikamu-operation.rb
118
+ - lib/hanikamu/operation.rb
119
+ homepage: https://github.com/Hanikamu/hanikamu-operation
120
+ licenses:
121
+ - MIT
122
+ metadata:
123
+ homepage_uri: https://github.com/Hanikamu/hanikamu-operation
124
+ source_code_uri: https://github.com/Hanikamu/hanikamu-operation
125
+ changelog_uri: https://github.com/Hanikamu/hanikamu-operation/blob/main/CHANGELOG.md
126
+ rubygems_mfa_required: 'true'
127
+ post_install_message:
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 3.2.0
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 3.5.3
143
+ signing_key:
144
+ specification_version: 4
145
+ summary: Service objects with guards, distributed locks, and transactions
146
+ test_files: []