omni_service 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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +209 -0
- data/lib/omni_service/async.rb +106 -0
- data/lib/omni_service/collection.rb +78 -0
- data/lib/omni_service/component.rb +81 -0
- data/lib/omni_service/context.rb +29 -0
- data/lib/omni_service/convenience.rb +125 -0
- data/lib/omni_service/error.rb +56 -0
- data/lib/omni_service/find_many.rb +261 -0
- data/lib/omni_service/find_one.rb +172 -0
- data/lib/omni_service/helpers.rb +20 -0
- data/lib/omni_service/inspect.rb +41 -0
- data/lib/omni_service/namespace.rb +111 -0
- data/lib/omni_service/optional.rb +49 -0
- data/lib/omni_service/parallel.rb +70 -0
- data/lib/omni_service/params.rb +50 -0
- data/lib/omni_service/result.rb +107 -0
- data/lib/omni_service/sequence.rb +53 -0
- data/lib/omni_service/shortcut.rb +49 -0
- data/lib/omni_service/strict.rb +36 -0
- data/lib/omni_service/transaction.rb +127 -0
- data/lib/omni_service/version.rb +5 -0
- data/lib/omni_service.rb +78 -0
- metadata +154 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 59a4a467df9c0035688d7084b232905e2e08690848f94155e848b19e892925d6
|
|
4
|
+
data.tar.gz: ea6a85ee1658f791d642e5ce3c2751e97a372458baeeecbb0f9c2715ccb2b01e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2b10b64150672cb32353ef97ee6a88f598e12cd1702e354a3966d7858b481e0a463e311524eb7a820ac9730af0484c3118a8b9f761b45a9b9569e5e2f158ac30
|
|
7
|
+
data.tar.gz: 29c6d905f728eaac6c7737b94d22bf2f7c5fc96c48f6176185e7fa3105176183c61965ceb30d102bba6543ab22c3499d49f036b7e06e6788a9f3c4aea9e95d4d
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Arkadiy Zabazhanov
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# OmniService Framework
|
|
2
|
+
|
|
3
|
+
Composable business operations with railway-oriented programming.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class Posts::Create
|
|
9
|
+
extend OmniService::Convenience
|
|
10
|
+
|
|
11
|
+
option :post_repo, default: -> { PostRepository.new }
|
|
12
|
+
|
|
13
|
+
def self.system
|
|
14
|
+
@system ||= sequence(
|
|
15
|
+
input,
|
|
16
|
+
transaction(create, on_success: [notify])
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.input
|
|
21
|
+
@input ||= parallel(
|
|
22
|
+
params { required(:title).filled(:string) },
|
|
23
|
+
FindOne.new(:author, repository: AuthorRepository.new, with: :author_id)
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.create
|
|
28
|
+
->(params, author:, **) { post_repo.create(params.merge(author:)) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result = Posts::Create.system.call({ title: 'Hello', author_id: 1 })
|
|
33
|
+
result.success? # => true
|
|
34
|
+
result.context # => { author: <Author>, post: <Post> }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Core Concepts
|
|
38
|
+
|
|
39
|
+
### Components
|
|
40
|
+
Any callable returning `Success(context_hash)` or `Failure(errors)`.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# Lambda
|
|
44
|
+
->(params, **ctx) { Success(post: Post.new(params)) }
|
|
45
|
+
|
|
46
|
+
# Class with #call
|
|
47
|
+
class ValidateTitle
|
|
48
|
+
def call(params, **)
|
|
49
|
+
params[:title].present? ? Success({}) : Failure([{ code: :blank, path: [:title] }])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Result
|
|
55
|
+
Structured output with: `context`, `params`, `errors`, `on_success`, `on_failure`.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
result.success? # no errors?
|
|
59
|
+
result.failure? # has errors?
|
|
60
|
+
result.context # { post: <Post>, author: <Author> }
|
|
61
|
+
result.errors # [#<Error code=:blank path=[:title]>]
|
|
62
|
+
result.to_monad # Success(result) or Failure(result)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Composition
|
|
66
|
+
|
|
67
|
+
### sequence
|
|
68
|
+
Runs components in order. Short-circuits on first failure.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
sequence(
|
|
72
|
+
validate_params, # Failure stops here
|
|
73
|
+
find_author, # Adds :author to context
|
|
74
|
+
create_post # Receives :author
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### parallel
|
|
79
|
+
Runs all components, collects all errors.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
parallel(
|
|
83
|
+
validate_title, # => Failure([{ path: [:title], code: :blank }])
|
|
84
|
+
validate_body # => Failure([{ path: [:body], code: :too_short }])
|
|
85
|
+
)
|
|
86
|
+
# => Result with both errors collected
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### transaction
|
|
90
|
+
Wraps in DB transaction with callbacks.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
transaction(
|
|
94
|
+
sequence(validate, create),
|
|
95
|
+
on_success: [send_email, update_cache], # After commit
|
|
96
|
+
on_failure: [log_error] # After rollback
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### namespace
|
|
101
|
+
Scopes params/context under a key.
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# params: { post: { title: 'Hi' }, author: { name: 'John' } }
|
|
105
|
+
parallel(
|
|
106
|
+
namespace(:post, validate_post),
|
|
107
|
+
namespace(:author, validate_author)
|
|
108
|
+
)
|
|
109
|
+
# Errors: [:post, :title], [:author, :name]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### collection
|
|
113
|
+
Iterates over arrays.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# params: { comments: [{ body: 'A' }, { body: '' }] }
|
|
117
|
+
collection(validate_comment, namespace: :comments)
|
|
118
|
+
# Errors: [:comments, 1, :body]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### optional
|
|
122
|
+
Swallows failures.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
sequence(
|
|
126
|
+
create_user,
|
|
127
|
+
optional(fetch_avatar), # Failure won't stop pipeline
|
|
128
|
+
send_email
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### shortcut
|
|
133
|
+
Early exit on success.
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
sequence(
|
|
137
|
+
shortcut(find_existing), # Found? Exit early
|
|
138
|
+
create_new # Not found? Create
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Entity Lookup
|
|
143
|
+
|
|
144
|
+
### FindOne
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
FindOne.new(:post, repository: repo)
|
|
148
|
+
# params: { post_id: 1 } => Success(post: <Post>)
|
|
149
|
+
|
|
150
|
+
# Options
|
|
151
|
+
FindOne.new(:post, repository: repo, with: :slug) # Custom param key
|
|
152
|
+
FindOne.new(:post, repository: repo, by: [:author_id, :slug]) # Multi-column
|
|
153
|
+
FindOne.new(:post, repository: repo, nullable: true) # Allow nil
|
|
154
|
+
FindOne.new(:post, repository: repo, omittable: true) # Allow missing key
|
|
155
|
+
FindOne.new(:post, repository: repo, skippable: true) # Skip not found
|
|
156
|
+
|
|
157
|
+
# Polymorphic
|
|
158
|
+
FindOne.new(:item, repository: { 'Post' => post_repo, 'Article' => article_repo })
|
|
159
|
+
# params: { item_id: 1, item_type: 'Post' }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### FindMany
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
FindMany.new(:posts, repository: repo)
|
|
166
|
+
# params: { post_ids: [1, 2, 3] } => Success(posts: [...])
|
|
167
|
+
|
|
168
|
+
# Nested IDs
|
|
169
|
+
FindMany.new(:products, repository: repo, by: { id: [:items, :product_id] })
|
|
170
|
+
# params: { items: [{ product_id: 1 }, { product_id: [2, 3] }] }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Error Format
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
Failure(:not_found)
|
|
177
|
+
# => Error(code: :not_found, path: [])
|
|
178
|
+
|
|
179
|
+
Failure([{ code: :blank, path: [:title] }])
|
|
180
|
+
# => Error(code: :blank, path: [:title])
|
|
181
|
+
|
|
182
|
+
Failure([{ code: :too_short, path: [:body], tokens: { min: 100 } }])
|
|
183
|
+
# => Error with interpolation tokens for i18n
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Async Execution
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
class Posts::Create
|
|
190
|
+
extend OmniService::Convenience
|
|
191
|
+
extend OmniService::Async::Convenience[queue: 'default']
|
|
192
|
+
|
|
193
|
+
def self.system
|
|
194
|
+
@system ||= sequence(...)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
Posts::Create.system_async.call(params, context)
|
|
199
|
+
# => Success(job_id: 'abc-123')
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Strict Mode
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
operation.call!(params) # Raises OmniService::OperationFailed on failure
|
|
206
|
+
|
|
207
|
+
# Or via convenience
|
|
208
|
+
Posts::Create.system!.call(params)
|
|
209
|
+
```
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Wraps operation execution in a background job (ActiveJob).
|
|
4
|
+
# Returns immediately with Success(job_id: ...) instead of operation result.
|
|
5
|
+
#
|
|
6
|
+
# @example Direct usage
|
|
7
|
+
# async = OmniService::Async.new(Posts::Create, :system, job_class: PostJob)
|
|
8
|
+
# async.call({ title: 'Hello' }, author: user)
|
|
9
|
+
# # => Success(job_id: 'abc-123')
|
|
10
|
+
#
|
|
11
|
+
# @example Via Convenience module (recommended)
|
|
12
|
+
# class Posts::Create
|
|
13
|
+
# extend OmniService::Convenience
|
|
14
|
+
# extend OmniService::Async::Convenience[queue: 'default', retry: 3]
|
|
15
|
+
#
|
|
16
|
+
# def self.system
|
|
17
|
+
# @system ||= sequence(validate_params, create_post)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Posts::Create.system_async.call({ title: 'Hello' }, author: user)
|
|
22
|
+
#
|
|
23
|
+
class OmniService::Async
|
|
24
|
+
extend Dry::Initializer
|
|
25
|
+
include Dry::Equalizer(:container_class, :container_method, :job_class, :job_options)
|
|
26
|
+
include OmniService::Inspect.new(:container_class, :container_method, :job_class, :job_options)
|
|
27
|
+
include Dry::Monads[:result]
|
|
28
|
+
|
|
29
|
+
# A helper module to simplify async operation creation.
|
|
30
|
+
#
|
|
31
|
+
# @example Basic usage
|
|
32
|
+
#
|
|
33
|
+
# class MyOperation
|
|
34
|
+
# extend OmniService::Convenience
|
|
35
|
+
# extend OmniService::Async::Convenience[queue: 'important', retry: 3]
|
|
36
|
+
#
|
|
37
|
+
# def self.system
|
|
38
|
+
# @system ||= sequence(...)
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# MyOperation.system_async.call!(...)
|
|
43
|
+
#
|
|
44
|
+
# @example With custom job class
|
|
45
|
+
#
|
|
46
|
+
# class MyOperation
|
|
47
|
+
# extend OmniService::Convenience
|
|
48
|
+
# extend OmniService::Async::Convenience[job_class: MyCustomJob]
|
|
49
|
+
#
|
|
50
|
+
# def self.system
|
|
51
|
+
# @system ||= sequence(...)
|
|
52
|
+
# end
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
module Convenience
|
|
56
|
+
def self.[](**job_options)
|
|
57
|
+
job_class = job_options.delete(:job_class) || OperationJob
|
|
58
|
+
|
|
59
|
+
Module.new do
|
|
60
|
+
define_singleton_method(:extended) do |base|
|
|
61
|
+
base.extend OmniService::Async::Convenience
|
|
62
|
+
base.instance_variable_set(:@job_options, job_options)
|
|
63
|
+
base.instance_variable_set(:@job_class, job_class)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def method_missing(name, ...)
|
|
69
|
+
name_without_suffix = name.to_s.delete_suffix('_async').to_sym
|
|
70
|
+
|
|
71
|
+
if name.to_s.end_with?('_async') && respond_to?(name_without_suffix)
|
|
72
|
+
ivar_name = :"@#{name}"
|
|
73
|
+
|
|
74
|
+
if instance_variable_defined?(ivar_name)
|
|
75
|
+
instance_variable_get(ivar_name)
|
|
76
|
+
else
|
|
77
|
+
instance_variable_set(
|
|
78
|
+
ivar_name,
|
|
79
|
+
OmniService::Async.new(
|
|
80
|
+
self,
|
|
81
|
+
name_without_suffix,
|
|
82
|
+
job_class: @job_class || OperationJob,
|
|
83
|
+
job_options: @job_options || {}
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
super
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def respond_to_missing?(name, *)
|
|
93
|
+
(name.to_s.end_with?('_async') && respond_to?(name.to_s.delete_suffix('_async').to_sym)) || super
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
param :container_class, OmniService::Types::Class
|
|
98
|
+
param :container_method, OmniService::Types::Symbol
|
|
99
|
+
option :job_class, OmniService::Types::Class, default: proc { OperationJob }
|
|
100
|
+
option :job_options, OmniService::Types::Hash, default: proc { {} }
|
|
101
|
+
|
|
102
|
+
def call(*params, **context)
|
|
103
|
+
job = job_class.set(job_options).perform_later(container_class, container_method, params, context)
|
|
104
|
+
Success(job_id: job.provider_job_id)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Iterates component over arrays in namespaced params and context.
|
|
4
|
+
# Collects results from all iterations; errors include array index in path.
|
|
5
|
+
#
|
|
6
|
+
# Extracts arrays from params[namespace] and context[namespace].
|
|
7
|
+
# Iteration count is max of array lengths. Missing indices get empty values.
|
|
8
|
+
# Error paths are prefixed with [namespace, index, ...].
|
|
9
|
+
#
|
|
10
|
+
# @example Create multiple comments for a post
|
|
11
|
+
# # params: { comments: [{ body: 'First' }, { body: '' }] }
|
|
12
|
+
# # context: { comments: [{ author: user1 }, { author: user2 }] }
|
|
13
|
+
# collection(create_comment, namespace: :comments)
|
|
14
|
+
# # => Result(
|
|
15
|
+
# # context: { comments: [{ comment: <Comment> }, { comment: nil }] },
|
|
16
|
+
# # errors: [{ path: [:comments, 1, :body], code: :blank }]
|
|
17
|
+
# # )
|
|
18
|
+
#
|
|
19
|
+
# @example Validate nested items
|
|
20
|
+
# collection(validate_line_item, namespace: :line_items)
|
|
21
|
+
# # Errors: [:line_items, 0, :quantity], [:line_items, 2, :price]
|
|
22
|
+
#
|
|
23
|
+
class OmniService::Collection
|
|
24
|
+
extend Dry::Initializer
|
|
25
|
+
include Dry::Equalizer(:component)
|
|
26
|
+
include OmniService::Inspect.new(:component)
|
|
27
|
+
include OmniService::Strict
|
|
28
|
+
|
|
29
|
+
param :component, OmniService::Types::Interface(:call)
|
|
30
|
+
option :namespace, OmniService::Types::Symbol
|
|
31
|
+
|
|
32
|
+
def call(*params, **context)
|
|
33
|
+
params_array = params.map { |param| param.symbolize_keys.fetch(namespace, []) }
|
|
34
|
+
context_array = context.fetch(namespace, [])
|
|
35
|
+
size = [*params_array.map(&:size), context_array.size].max
|
|
36
|
+
|
|
37
|
+
results = (0...size).map do |index|
|
|
38
|
+
call_wrapper(params_array, context_array, context, index)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
compose_result(results, context)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def signature
|
|
45
|
+
@signature ||= [component_wrapper.signature.first, true]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def call_wrapper(params_array, context_array, context, index)
|
|
51
|
+
component_wrapper.call(
|
|
52
|
+
*params_array.pluck(index),
|
|
53
|
+
**context.except(namespace),
|
|
54
|
+
**(context_array[index] || {})
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def compose_result(results, context)
|
|
59
|
+
OmniService::Result.build(
|
|
60
|
+
self,
|
|
61
|
+
params: results.map(&:params).transpose.map { |param| { namespace => param } },
|
|
62
|
+
context: context.merge(namespace => results.map(&:context)),
|
|
63
|
+
errors: compose_errors(results),
|
|
64
|
+
on_success: results.flat_map(&:on_success),
|
|
65
|
+
on_failure: results.flat_map(&:on_failure)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compose_errors(results)
|
|
70
|
+
results.flat_map.with_index do |result, index|
|
|
71
|
+
result.errors.map { |error| error.new(path: [namespace, index, *error.path]) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def component_wrapper
|
|
76
|
+
@component_wrapper ||= OmniService::Component.wrap(component)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Wraps any callable (lambda, proc, object responding to #call) and normalizes its execution.
|
|
4
|
+
# Detects callable signature to properly dispatch params and context.
|
|
5
|
+
#
|
|
6
|
+
# Supported signatures:
|
|
7
|
+
# - `(**context)` - receives only context
|
|
8
|
+
# - `(params)` - receives only first param
|
|
9
|
+
# - `(params, **context)` - receives first param and context
|
|
10
|
+
# - `(p1, p2, ..., **context)` - receives multiple params and context
|
|
11
|
+
#
|
|
12
|
+
# @example Lambda with params and context
|
|
13
|
+
# component = OmniService::Component.new(->(params, **ctx) { Success(post: params) })
|
|
14
|
+
# component.call({ title: 'Hello' }, author: current_user)
|
|
15
|
+
# # => Result(context: { author: current_user, post: { title: 'Hello' } })
|
|
16
|
+
#
|
|
17
|
+
# @example Context-only callable
|
|
18
|
+
# component = OmniService::Component.new(->(**ctx) { Success(greeted: ctx[:user].name) })
|
|
19
|
+
# component.call({}, user: User.new(name: 'John'))
|
|
20
|
+
# # => Result(context: { user: ..., greeted: 'John' })
|
|
21
|
+
#
|
|
22
|
+
class OmniService::Component
|
|
23
|
+
extend Dry::Initializer
|
|
24
|
+
include Dry::Monads[:result]
|
|
25
|
+
include Dry::Equalizer(:callable)
|
|
26
|
+
include OmniService::Inspect.new(:callable)
|
|
27
|
+
include OmniService::Strict
|
|
28
|
+
|
|
29
|
+
MONADS_DO_WRAPPER_SIGNATURES = [
|
|
30
|
+
[%i[rest *], %i[block &]],
|
|
31
|
+
[%i[rest], %i[block &]], # Ruby 3.0, 3.1
|
|
32
|
+
[%i[rest *], %i[keyrest **], %i[block &]],
|
|
33
|
+
[%i[rest], %i[keyrest], %i[block &]] # Ruby 3.0, 3.1
|
|
34
|
+
].freeze
|
|
35
|
+
DEFAULT_NAMES_MAP = { rest: '*', keyrest: '**' }.freeze # Ruby 3.0, 3.1
|
|
36
|
+
|
|
37
|
+
param :callable, OmniService::Types::Interface(:call)
|
|
38
|
+
|
|
39
|
+
def self.wrap(value)
|
|
40
|
+
if value.is_a?(Array)
|
|
41
|
+
value.map { |item| wrap(item) }
|
|
42
|
+
elsif value.respond_to?(:signature)
|
|
43
|
+
value
|
|
44
|
+
else
|
|
45
|
+
new(value)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call(*params, **context)
|
|
50
|
+
callable_result = case signature
|
|
51
|
+
in [0, true]
|
|
52
|
+
callable.call(**context)
|
|
53
|
+
in [n, false]
|
|
54
|
+
callable.call(*params[...n])
|
|
55
|
+
in [n, true]
|
|
56
|
+
callable.call(*params[...n], **context)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
OmniService::Result.build(callable, params:, context:).merge(OmniService::Result.process(callable, callable_result))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def signature
|
|
63
|
+
@signature ||= [
|
|
64
|
+
call_args(%i[rest]).empty? ? call_args(%i[req opt]).size : call_args(%i[rest]).size,
|
|
65
|
+
!call_args(%i[key keyreq keyrest]).empty?
|
|
66
|
+
]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def call_args(types)
|
|
72
|
+
call_method = callable.respond_to?(:parameters) ? callable : callable.method(:call)
|
|
73
|
+
# calling super_method here because `OmniService::Convenience`
|
|
74
|
+
# calls `include Dry::Monads::Do.for(:call)` which creates
|
|
75
|
+
# a delegator method around the original one.
|
|
76
|
+
call_method = call_method.super_method if MONADS_DO_WRAPPER_SIGNATURES.include?(call_method.parameters)
|
|
77
|
+
call_method.parameters.filter_map do |(type, name)|
|
|
78
|
+
name || DEFAULT_NAMES_MAP[type] if types.include?(type)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Validates caller-provided context values against type specifications.
|
|
4
|
+
# Use for values passed at call time (current_user, request), not fetched entities.
|
|
5
|
+
# Raises Dry::Types::CoercionError on type mismatches (not validation errors).
|
|
6
|
+
# Only validates keys defined in schema; passes through undefined keys unchanged.
|
|
7
|
+
#
|
|
8
|
+
# @example Validate caller-provided context
|
|
9
|
+
# context(
|
|
10
|
+
# current_user: Types::Instance(User),
|
|
11
|
+
# correlation_id: Types::String.optional,
|
|
12
|
+
# logger: Types::Interface(:info, :error)
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
class OmniService::Context
|
|
16
|
+
extend Dry::Initializer
|
|
17
|
+
include Dry::Monads[:result]
|
|
18
|
+
|
|
19
|
+
param :schema, OmniService::Types::Hash.map(OmniService::Types::Symbol, OmniService::Types::Interface(:call))
|
|
20
|
+
|
|
21
|
+
def call(*params, **context)
|
|
22
|
+
validated = schema.to_h { |key, type| [key, type.call(context[key])] }
|
|
23
|
+
OmniService::Result.build(self, params:, context: context.merge(validated))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def signature
|
|
27
|
+
[-1, true]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# DSL module for building operations with composable components.
|
|
4
|
+
# Provides factory methods and includes common dependencies.
|
|
5
|
+
#
|
|
6
|
+
# Includes: Dry::Initializer, Dry::Monads[:result], Dry::Monads::Do, Dry::Core::Constants
|
|
7
|
+
#
|
|
8
|
+
# Factory methods: sequence, parallel, transaction, optional, shortcut,
|
|
9
|
+
# namespace, collection, context, params, noop
|
|
10
|
+
#
|
|
11
|
+
# Bang methods: Appending ! to any method returns a callable that uses call!
|
|
12
|
+
# e.g., MyOp.create! returns MyOp.create.method(:call!)
|
|
13
|
+
#
|
|
14
|
+
# @example Typical operation class
|
|
15
|
+
# class Posts::Create
|
|
16
|
+
# extend OmniService::Convenience
|
|
17
|
+
#
|
|
18
|
+
# option :post_repo, default: -> { PostRepository.new }
|
|
19
|
+
#
|
|
20
|
+
# def self.system
|
|
21
|
+
# @system ||= sequence(
|
|
22
|
+
# input,
|
|
23
|
+
# transaction(create, on_success: [notify])
|
|
24
|
+
# )
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# def self.input
|
|
28
|
+
# @input ||= parallel(
|
|
29
|
+
# params { required(:title).filled(:string) },
|
|
30
|
+
# find_author
|
|
31
|
+
# )
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# def self.create
|
|
35
|
+
# ->(params, author:, **) { post_repo.create(params.merge(author:)) }
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# Posts::Create.system.call({ title: 'Hello' }, current_user:)
|
|
40
|
+
# Posts::Create.system!.call({ title: 'Hello' }, current_user:) # raises on failure
|
|
41
|
+
#
|
|
42
|
+
module OmniService::Convenience
|
|
43
|
+
def self.extended(mod)
|
|
44
|
+
mod.extend Dry::Initializer
|
|
45
|
+
mod.include Dry::Monads[:result]
|
|
46
|
+
mod.include Dry::Monads::Do.for(:call)
|
|
47
|
+
mod.include Dry::Core::Constants
|
|
48
|
+
mod.include OmniService::Helpers
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def method_missing(name, ...)
|
|
52
|
+
name_without_suffix = name.to_s.delete_suffix('!').to_sym
|
|
53
|
+
if name.to_s.end_with?('!') && respond_to?(name_without_suffix)
|
|
54
|
+
public_send(name_without_suffix, ...).method(:call!)
|
|
55
|
+
else
|
|
56
|
+
super
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def respond_to_missing?(name, *)
|
|
61
|
+
(name.to_s.end_with?('!') && respond_to?(name.to_s.delete_suffix('!').to_sym)) || super
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def noop
|
|
65
|
+
->(_, **) { Dry::Monads::Success({}) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sequence(...)
|
|
69
|
+
OmniService::Sequence.new(...)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parallel(...)
|
|
73
|
+
OmniService::Parallel.new(...)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def collection(...)
|
|
77
|
+
OmniService::Collection.new(...)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def transaction(...)
|
|
81
|
+
OmniService::Transaction.new(...)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def shortcut(...)
|
|
85
|
+
OmniService::Shortcut.new(...)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def optional(...)
|
|
89
|
+
OmniService::Optional.new(...)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def namespace(...)
|
|
93
|
+
OmniService::Namespace.new(...)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def params(**, &)
|
|
97
|
+
OmniService::Params.params(**, &)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def context(schema)
|
|
101
|
+
OmniService::Context.new(schema)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def component(name, from = Object, **options, &block)
|
|
105
|
+
raise ArgumentError, "Please provide either a superclass or a block for #{name}" unless from || block
|
|
106
|
+
|
|
107
|
+
klass = Class.new(from)
|
|
108
|
+
klass.extend(Dry::Initializer) unless klass.include?(Dry::Initializer)
|
|
109
|
+
klass.include(Dry::Monads[:result]) unless klass.is_a?(Dry::Monads[:result])
|
|
110
|
+
klass.class_eval do
|
|
111
|
+
options.each do |name, type|
|
|
112
|
+
option name, type
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
klass.define_method(:call, &block) if block
|
|
116
|
+
|
|
117
|
+
const_set(name.to_s.camelize, klass)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
%w[policy precondition callback].each do |kind|
|
|
121
|
+
define_method kind do |prefix = nil, from: OmniService::Component, &block|
|
|
122
|
+
component([prefix, kind].compact.join('_'), from:, &block)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|