zen-service 2.2.4 → 2.3.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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +182 -2
- data/lib/zen/service/callable.rb +11 -0
- data/lib/zen/service/plugins/inputs.rb +82 -0
- data/lib/zen/service/plugins/pluggable.rb +1 -1
- data/lib/zen/service/plugins.rb +1 -0
- data/lib/zen/service/version.rb +1 -1
- data/lib/zen/service.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b540abd37b4fb43937241ab38aab9e143afc8afa80683002ccc32dace163ab7c
|
|
4
|
+
data.tar.gz: 9d0a5e5a0e6bbf2e2473bc8b3337edab1ea65773d163336a53a1ef9741a95745
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5c28f74692f0ea39242111cf419c116d51586945f08e5523996f40d578884a8797934ca153251f08802b464569916e898b13687648b27ed100895baf528e011
|
|
7
|
+
data.tar.gz: a5fd404f08a6ea273d4a73bffdfeb9aec2a78cd9894ee600338de80f0b87892a825eeaf592d3f44772b137a5f3cea8c27f679140a093a80920575154ddca51dd
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.3.0] - 2026-02-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Add `:inputs` plugin as an experimental alternative to `:attributes` for service initialization
|
|
13
|
+
- Provides keyword-only initialization with built-in runtime validation
|
|
14
|
+
- Supports per-input validation blocks with Ruby 3+ pattern matching
|
|
15
|
+
- Includes optional inputs with lazy-evaluated defaults
|
|
16
|
+
- Adds initialization blocks for computed attributes
|
|
17
|
+
- Designed for use with `Zen::Service::Callable` descendants
|
|
18
|
+
- Add `Zen::Service::Callable` base class as service "blank slate"
|
|
19
|
+
- Inherits only `:callable` plugin without `:attributes`
|
|
20
|
+
- Enables alternative initialization strategies via plugins like `:inputs`
|
|
21
|
+
- Add comprehensive benchmark comparison with `verbalize` gem in README
|
|
22
|
+
- Shows `zen-service` with `:attributes` is ~31% faster than `verbalize`
|
|
23
|
+
- Shows `:inputs` plugin is ~8% faster than `verbalize`
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Update README with `:inputs` plugin documentation and usage examples
|
|
28
|
+
- Update README with pattern matching validation examples
|
|
29
|
+
- Improve documentation structure and clarity
|
|
30
|
+
|
|
8
31
|
## [2.2.4] - 2026-01-19
|
|
9
32
|
|
|
10
33
|
### Fixed
|
|
@@ -94,6 +117,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
94
117
|
- Most built-in plugins from v1.x for simplicity
|
|
95
118
|
- Removed legacy plugin APIs
|
|
96
119
|
|
|
120
|
+
[2.2.4]: https://github.com/akuzko/zen-service/compare/v2.2.3...v2.2.4
|
|
121
|
+
[2.3.0]: https://github.com/akuzko/zen-service/compare/v2.2.4...v2.3.0
|
|
97
122
|
[2.2.4]: https://github.com/akuzko/zen-service/compare/v2.2.3...v2.2.4
|
|
98
123
|
[2.2.3]: https://github.com/akuzko/zen-service/compare/v2.2.2...v2.2.3
|
|
99
124
|
[2.2.2]: https://github.com/akuzko/zen-service/compare/v2.2.1...v2.2.2
|
data/README.md
CHANGED
|
@@ -150,9 +150,11 @@ class Logger < Zen::Service
|
|
|
150
150
|
|
|
151
151
|
# Will result with value return by `yield` expression
|
|
152
152
|
def call
|
|
153
|
+
start_time = Time.now
|
|
153
154
|
Rails.logger.info("Starting operation")
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
yield
|
|
156
|
+
time_taken = (Time.now - start_time) * 1000
|
|
157
|
+
Rails.logger.info("Operation completed in #{time_taken.round} ms")
|
|
156
158
|
end
|
|
157
159
|
end
|
|
158
160
|
|
|
@@ -170,6 +172,114 @@ class UpdateTodo < Zen::Service
|
|
|
170
172
|
end
|
|
171
173
|
```
|
|
172
174
|
|
|
175
|
+
#### `:inputs` (Experimental)
|
|
176
|
+
|
|
177
|
+
Provides an alternative way to initialize services with keyword-only arguments and built-in runtime validation.
|
|
178
|
+
|
|
179
|
+
**Key Features:**
|
|
180
|
+
|
|
181
|
+
- Keyword-only initialization (no positional arguments)
|
|
182
|
+
- Per-input validation blocks with Ruby 3+ pattern matching
|
|
183
|
+
- Optional inputs with lazy-evaluated defaults
|
|
184
|
+
- Initialization blocks for computed attributes
|
|
185
|
+
|
|
186
|
+
**⚠️ Experimental Feature:** To avoid breaking changes, services using `:inputs` should inherit from
|
|
187
|
+
`Zen::Service::Callable` instead of `Zen::Service`. The base `Zen::Service` class already includes the
|
|
188
|
+
`:attributes` plugin, and these two plugins provide different initialization strategies.
|
|
189
|
+
API of the plugin may be changed in future.
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# Base class for input-based services
|
|
193
|
+
class ApplicationCallable < Zen::Service::Callable
|
|
194
|
+
use :inputs
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Service with input validation
|
|
198
|
+
class CalculatePrice < ApplicationCallable
|
|
199
|
+
input(:quantity) { _1 => Integer }
|
|
200
|
+
input(:unit_price) { _1 => Numeric }
|
|
201
|
+
input(:discount, optional: true)
|
|
202
|
+
|
|
203
|
+
def call
|
|
204
|
+
total = quantity * unit_price
|
|
205
|
+
discount ? total * (1 - discount) : total
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
CalculatePrice.call(quantity: 10, unit_price: 5.0, discount: 0.1)
|
|
210
|
+
# => 45.0
|
|
211
|
+
|
|
212
|
+
CalculatePrice.call(quantity: "10", unit_price: 5.0)
|
|
213
|
+
# => NoMatchingPatternError (Integer === "10" does not return true)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Input Options:**
|
|
217
|
+
|
|
218
|
+
- `optional: true` - Allow input to be omitted (defaults to `nil`)
|
|
219
|
+
- `default: -> { value }` - Provide lazy-evaluated default value
|
|
220
|
+
|
|
221
|
+
**Bulk Definition with Validation:**
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class ProcessCoordinates < ApplicationCallable
|
|
225
|
+
inputs(:x, :y) do |x_val, y_val|
|
|
226
|
+
x_val => Integer
|
|
227
|
+
y_val => Integer
|
|
228
|
+
raise ArgumentError, "coordinates out of bounds" if x_val.abs > 100 || y_val.abs > 100
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def call
|
|
232
|
+
[x, y]
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
ProcessCoordinates.call(x: 10, y: 20) # => [10, 20]
|
|
237
|
+
ProcessCoordinates.call(x: 150, y: 20) # => ArgumentError: coordinates out of bounds
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Default Values:**
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
class CreateReport < ApplicationCallable
|
|
244
|
+
input :data
|
|
245
|
+
input :format, default: -> { :json }
|
|
246
|
+
input :timestamp, default: -> { Time.current }
|
|
247
|
+
|
|
248
|
+
def call
|
|
249
|
+
{ data: data, format: format, timestamp: timestamp }
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
CreateReport.call(data: [1, 2, 3])
|
|
254
|
+
# => { data: [1, 2, 3], format: :json, timestamp: 2026-02-02 10:30:00 UTC }
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Pattern Matching for Type Safety:**
|
|
258
|
+
|
|
259
|
+
The primary use case for validation blocks is runtime type checking using Ruby's pattern matching:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
class UserRegistration < ApplicationCallable
|
|
263
|
+
input(:email) { _1 => String }
|
|
264
|
+
input(:age) { _1 => Integer if _1 >= 18 }
|
|
265
|
+
input(:role) { _1 => :admin | :user | :guest }
|
|
266
|
+
|
|
267
|
+
def call
|
|
268
|
+
# Your registration logic
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Valid calls
|
|
273
|
+
UserRegistration.call(email: "user@example.com", age: 25, role: :user)
|
|
274
|
+
|
|
275
|
+
# Pattern match failures
|
|
276
|
+
UserRegistration.call(email: 123, age: 25, role: :user)
|
|
277
|
+
# => NoMatchingPatternError
|
|
278
|
+
|
|
279
|
+
UserRegistration.call(email: "user@example.com", age: 15, role: :user)
|
|
280
|
+
# => NoMatchingPatternError
|
|
281
|
+
```
|
|
282
|
+
|
|
173
283
|
### Creating Custom Plugins
|
|
174
284
|
|
|
175
285
|
Creating custom plugins is straightforward. Below is an example of a plugin that transforms results to
|
|
@@ -273,6 +383,76 @@ module MyPlugin
|
|
|
273
383
|
end
|
|
274
384
|
```
|
|
275
385
|
|
|
386
|
+
## Comparison/Benchmark
|
|
387
|
+
|
|
388
|
+
`zen-service` is designed to be both flexible and performant. Among similar service object gems,
|
|
389
|
+
[verbalize](https://github.com/taylorzr/verbalize) is known for being the fastest implementation.
|
|
390
|
+
The following benchmark compares `zen-service` with `verbalize` using a simple addition service:
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
require 'benchmark/ips'
|
|
394
|
+
require 'verbalize'
|
|
395
|
+
require 'zen/service'
|
|
396
|
+
|
|
397
|
+
class VerbalizeAdd
|
|
398
|
+
include Verbalize::Action
|
|
399
|
+
|
|
400
|
+
input :a, :b
|
|
401
|
+
|
|
402
|
+
def call
|
|
403
|
+
a + b
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
class ZenAdd < Zen::Service
|
|
408
|
+
attributes :a, :b
|
|
409
|
+
|
|
410
|
+
def call
|
|
411
|
+
a + b
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
class ZenInputsAdd < Zen::Service::Callable
|
|
416
|
+
use :inputs
|
|
417
|
+
|
|
418
|
+
inputs :a, :b
|
|
419
|
+
|
|
420
|
+
def call
|
|
421
|
+
a + b
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
Benchmark.ips do |x|
|
|
426
|
+
x.report('Verbalize ') { VerbalizeAdd.call(a: 1, b: 2) }
|
|
427
|
+
x.report('Zen (attributes)') { ZenAdd.call(a: 1, b: 2) }
|
|
428
|
+
x.report('Zen (inputs) ') { ZenInputsAdd.call(a: 1, b: 2) }
|
|
429
|
+
x.compare!
|
|
430
|
+
end
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
**Results:**
|
|
434
|
+
|
|
435
|
+
```
|
|
436
|
+
Warming up --------------------------------------
|
|
437
|
+
Verbalize 66.313k i/100ms
|
|
438
|
+
Zen (attributes) 87.104k i/100ms
|
|
439
|
+
Zen (inputs) 62.857k i/100ms
|
|
440
|
+
Calculating -------------------------------------
|
|
441
|
+
Verbalize 648.660k (± 2.4%) i/s (1.54 μs/i) - 3.249M in 5.012220s
|
|
442
|
+
Zen (attributes) 848.953k (± 4.2%) i/s (1.18 μs/i) - 4.268M in 5.037257s
|
|
443
|
+
Zen (inputs) 701.096k (± 3.1%) i/s (1.43 μs/i) - 3.520M in 5.026014s
|
|
444
|
+
|
|
445
|
+
Comparison:
|
|
446
|
+
Zen (attributes): 848952.8 i/s
|
|
447
|
+
Zen (inputs) : 701095.7 i/s - 1.21x slower
|
|
448
|
+
Verbalize : 648660.4 i/s - 1.31x slower
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
`zen-service` with the default `:attributes` plugin outperforms `verbalize` by ~31%, while the
|
|
452
|
+
experimental `:inputs` plugin is ~8% faster than `verbalize`. These benchmarks demonstrate that
|
|
453
|
+
`zen-service` provides excellent performance while offering superior extensibility through its
|
|
454
|
+
plugin system.
|
|
455
|
+
|
|
276
456
|
## Testing
|
|
277
457
|
|
|
278
458
|
The gem has 100% test coverage with both line and branch coverage. To run the test suite:
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zen
|
|
4
|
+
module Service::Plugins
|
|
5
|
+
module Inputs
|
|
6
|
+
extend Plugin
|
|
7
|
+
|
|
8
|
+
class Input < Data.define(:name, :optional, :default, :block)
|
|
9
|
+
def default_or_raise!
|
|
10
|
+
return default&.call if optional?
|
|
11
|
+
|
|
12
|
+
raise(ArgumentError, "input #{name} is required")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def optional?
|
|
16
|
+
!default.nil? || optional
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
InitInputsBlock = Data.define(:input_names, :block)
|
|
21
|
+
|
|
22
|
+
module ClassMethods
|
|
23
|
+
def inherited(service_class)
|
|
24
|
+
service_class.inputs_list.replace(inputs_list.dup)
|
|
25
|
+
service_class.init_inputs_blocks.replace(init_inputs_blocks.dup)
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def input(name, optional: false, default: nil, &block)
|
|
30
|
+
inputs_list.push(Input.new(name, optional, default, block))
|
|
31
|
+
define_method(name) { @inputs.fetch(name) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def inputs(*args, **kwargs, &block)
|
|
35
|
+
args.each { input(_1) }
|
|
36
|
+
kwargs.each { |name, default| input(name, default: default) }
|
|
37
|
+
init_inputs_blocks.push(InitInputsBlock.new(args + kwargs.keys, block)) if block
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inputs_list
|
|
41
|
+
@inputs_list ||= []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def input_names
|
|
45
|
+
inputs_list.map(&:name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def init_inputs_blocks
|
|
49
|
+
@init_inputs_blocks ||= []
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :inputs
|
|
54
|
+
|
|
55
|
+
def initialize(**kwargs)
|
|
56
|
+
@inputs = assert_valid_inputs!(kwargs)
|
|
57
|
+
self.class.init_inputs_blocks.each do |init_block|
|
|
58
|
+
instance_exec(*inputs.values_at(*init_block.input_names), &init_block.block)
|
|
59
|
+
end
|
|
60
|
+
super()
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize_clone(*)
|
|
64
|
+
super
|
|
65
|
+
@inputs = @inputs.dup
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def assert_valid_inputs!(actual) # rubocop:disable Metrics/AbcSize
|
|
71
|
+
unexpected = actual.keys - self.class.input_names
|
|
72
|
+
raise(ArgumentError, "wrong inputs #{unexpected.join(', ')} given") if unexpected.any?
|
|
73
|
+
|
|
74
|
+
self.class.inputs_list.each_with_object({}) do |reflection, result|
|
|
75
|
+
input_name = reflection.name
|
|
76
|
+
result[input_name] = actual.key?(input_name) ? actual[input_name] : reflection.default_or_raise!
|
|
77
|
+
instance_exec(result[input_name], &reflection.block) if reflection.block
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/zen/service/plugins.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Zen
|
|
|
41
41
|
require_relative "plugins/pluggable"
|
|
42
42
|
require_relative "plugins/callable"
|
|
43
43
|
require_relative "plugins/attributes"
|
|
44
|
+
require_relative "plugins/inputs"
|
|
44
45
|
require_relative "plugins/persisted_result"
|
|
45
46
|
require_relative "plugins/result_yielding"
|
|
46
47
|
end
|
data/lib/zen/service/version.rb
CHANGED
data/lib/zen/service.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zen-service
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artem Kuzko
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-02-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Flexible and highly extensible Services for business logic organization
|
|
14
14
|
email:
|
|
@@ -31,9 +31,11 @@ files:
|
|
|
31
31
|
- bin/console
|
|
32
32
|
- bin/setup
|
|
33
33
|
- lib/zen/service.rb
|
|
34
|
+
- lib/zen/service/callable.rb
|
|
34
35
|
- lib/zen/service/plugins.rb
|
|
35
36
|
- lib/zen/service/plugins/attributes.rb
|
|
36
37
|
- lib/zen/service/plugins/callable.rb
|
|
38
|
+
- lib/zen/service/plugins/inputs.rb
|
|
37
39
|
- lib/zen/service/plugins/persisted_result.rb
|
|
38
40
|
- lib/zen/service/plugins/pluggable.rb
|
|
39
41
|
- lib/zen/service/plugins/plugin.rb
|