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 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