service_operation 1.0.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/lib/service_operation.rb +17 -0
- data/lib/service_operation/base.rb +73 -0
- data/lib/service_operation/context.rb +122 -0
- data/lib/service_operation/delay.rb +22 -0
- data/lib/service_operation/error_handling.rb +94 -0
- data/lib/service_operation/errors.rb +46 -0
- data/lib/service_operation/failure.rb +13 -0
- data/lib/service_operation/hooks.rb +102 -0
- data/lib/service_operation/params.rb +129 -0
- data/lib/service_operation/params/attribute.rb +164 -0
- data/lib/service_operation/params/dsl.rb +79 -0
- data/lib/service_operation/params/types.rb +106 -0
- data/lib/service_operation/rack_mountable.rb +74 -0
- data/lib/service_operation/service_notification.rb +92 -0
- data/lib/service_operation/spec/spec_helper.rb +44 -0
- data/lib/service_operation/spec/support/action_contexts.rb +56 -0
- data/lib/service_operation/spec/support/operation_contexts.rb +56 -0
- data/lib/service_operation/validations.rb +29 -0
- data/lib/service_operation/version.rb +5 -0
- metadata +103 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 2a9c91b80daec619f0ac9a118b0f906f5696b6b5ddc922bad13e0d1442b34579
|
4
|
+
data.tar.gz: f1cfb8bb8ce55ed003407fb459fed455f8eb69d702985053c2a54bb05a23b940
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: edef05436b475d604291d233d04782e559e6d12c5236a4a1e95b926d21995a93432da0b20bc5875791d0606214ae7c2ff4a227250b397759fc5d8eb6cc75fd02
|
7
|
+
data.tar.gz: 5366dea3df77fb13306e4f4b825fcca84b717bc406d4d71abce5b2c85c6b0425608d04f7d93f06adee992ca38df77cb8f6d4304734bdd15be6133f90c80c2f0d
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# ServiceOperation
|
4
|
+
module ServiceOperation
|
5
|
+
autoload :Base, 'service_operation/base'
|
6
|
+
autoload :Context, 'service_operation/context'
|
7
|
+
autoload :Delay, 'service_operation/delay'
|
8
|
+
autoload :Errors, 'service_operation/errors'
|
9
|
+
autoload :ErrorHandling, 'service_operation/error_handling'
|
10
|
+
autoload :Failure, 'service_operation/failure'
|
11
|
+
autoload :Hooks, 'service_operation/hooks'
|
12
|
+
autoload :Input, 'service_operation/input'
|
13
|
+
autoload :Params, 'service_operation/params'
|
14
|
+
autoload :RackMountable, 'service_operation/rack_mountable'
|
15
|
+
autoload :Validations, 'service_operation/validations'
|
16
|
+
autoload :VERSION, 'service_operation/version'
|
17
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# ServiceOperation
|
4
|
+
module ServiceOperation
|
5
|
+
# Base
|
6
|
+
module Base
|
7
|
+
def self.included(base)
|
8
|
+
base.class_eval do
|
9
|
+
extend ClassMethods
|
10
|
+
|
11
|
+
include Delay
|
12
|
+
include ErrorHandling
|
13
|
+
include Hooks
|
14
|
+
include Params
|
15
|
+
include Validations
|
16
|
+
|
17
|
+
attr_reader :context
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# Class Methods
|
23
|
+
#
|
24
|
+
module ClassMethods
|
25
|
+
def call(context = {})
|
26
|
+
new(context).tap(&:run).context
|
27
|
+
end
|
28
|
+
|
29
|
+
def call!(context = {})
|
30
|
+
new(context).tap(&:run!).context
|
31
|
+
end
|
32
|
+
|
33
|
+
# Allow use via ProxyAction
|
34
|
+
def allow_remote!
|
35
|
+
@allow_remote = true
|
36
|
+
end
|
37
|
+
|
38
|
+
def allow_remote
|
39
|
+
@allow_remote || false
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
#
|
44
|
+
# Instance Methods
|
45
|
+
#
|
46
|
+
|
47
|
+
def initialize(context = {})
|
48
|
+
@context = Context.build(context)
|
49
|
+
end
|
50
|
+
|
51
|
+
def call
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
|
55
|
+
def run
|
56
|
+
run!
|
57
|
+
rescue Failure
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
|
61
|
+
def run!
|
62
|
+
with_hooks { fail_if_errors! || skip || call } && true
|
63
|
+
end
|
64
|
+
|
65
|
+
def skip
|
66
|
+
context.skip || false
|
67
|
+
end
|
68
|
+
|
69
|
+
def skip!
|
70
|
+
context.skip = true
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ostruct'
|
4
|
+
|
5
|
+
module ServiceOperation
|
6
|
+
# Context for an Operation
|
7
|
+
class Context < OpenStruct
|
8
|
+
CALLER_NAME_REGEXP = /`(rescue in |)([^']*)'$/
|
9
|
+
|
10
|
+
#
|
11
|
+
# Class Methods
|
12
|
+
#
|
13
|
+
|
14
|
+
def self.build(context = {})
|
15
|
+
self === context ? context : new(context) # rubocop:disable Style/CaseEquality
|
16
|
+
end
|
17
|
+
|
18
|
+
#
|
19
|
+
# Instance Methods
|
20
|
+
#
|
21
|
+
|
22
|
+
# Fixes stack loop issue with OpenStruct
|
23
|
+
# @requires ActiveModel::Serializers
|
24
|
+
def as_json(*args)
|
25
|
+
to_h.as_json(*args)
|
26
|
+
end
|
27
|
+
|
28
|
+
def fail!(context = {})
|
29
|
+
context.each { |k, v| send("#{k}=", v) }
|
30
|
+
@failure = true
|
31
|
+
|
32
|
+
raise Failure, self
|
33
|
+
end
|
34
|
+
|
35
|
+
# Use in the Operation class to create eager loading objects:
|
36
|
+
#
|
37
|
+
# @example
|
38
|
+
# def Operation#record
|
39
|
+
# context.coerce_if(Integer, String) { |name| Model.find_by_param(name) }
|
40
|
+
# end
|
41
|
+
#
|
42
|
+
# Operation.call(record: 'something').record == <Model name='something'>
|
43
|
+
def coerce_if(*klasses)
|
44
|
+
field = klasses.shift if klasses.first.is_a?(Symbol)
|
45
|
+
field ||= caller(1..1).first[CALLER_NAME_REGEXP, 2] # method name fetch was called from
|
46
|
+
self[field] = yield(self[field]) if klasses.any? { |k| self[field].is_a?(k) }
|
47
|
+
self[field]
|
48
|
+
end
|
49
|
+
|
50
|
+
# @example
|
51
|
+
# fetch(:field, 'value')
|
52
|
+
# @example
|
53
|
+
# fetch(:field) { 'value' }
|
54
|
+
# @example will infer field name from enclosing method name
|
55
|
+
# def enclosing_method
|
56
|
+
# context.fetch { 'value' }
|
57
|
+
# end
|
58
|
+
# @example
|
59
|
+
# def enclosing_method
|
60
|
+
# context.fetch 'value'
|
61
|
+
# end
|
62
|
+
def fetch(*args)
|
63
|
+
if !block_given? && args.length == 1 # context.fetch 'value'
|
64
|
+
new_value = args.first
|
65
|
+
else
|
66
|
+
field, new_value = args
|
67
|
+
end
|
68
|
+
|
69
|
+
field ||= caller(1..1).first[CALLER_NAME_REGEXP, 2] # method name fetch was called from
|
70
|
+
|
71
|
+
# field is already set
|
72
|
+
value = send(field)
|
73
|
+
return value if value
|
74
|
+
|
75
|
+
# context.fetch { block }
|
76
|
+
if block_given?
|
77
|
+
begin
|
78
|
+
value ||= yield
|
79
|
+
rescue StandardError => e
|
80
|
+
# apply if this context to the field, if this is first instance of this error being raised
|
81
|
+
self[field] = e.context if e.respond_to?(:context) &&
|
82
|
+
e.context.is_a?(self.class) &&
|
83
|
+
e.context != self &&
|
84
|
+
e.context.not_yet_raised
|
85
|
+
|
86
|
+
raise e
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
value ||= new_value
|
91
|
+
|
92
|
+
self[field] = value
|
93
|
+
|
94
|
+
value
|
95
|
+
end
|
96
|
+
|
97
|
+
# @return [true, false] first call returns false, after that true.
|
98
|
+
def not_yet_raised
|
99
|
+
if @already_raised
|
100
|
+
false
|
101
|
+
else
|
102
|
+
@already_raised = true
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def failure?
|
107
|
+
@failure || false
|
108
|
+
end
|
109
|
+
|
110
|
+
def success?
|
111
|
+
!failure?
|
112
|
+
end
|
113
|
+
|
114
|
+
def to_h(*args)
|
115
|
+
if args.any?
|
116
|
+
super().slice(*args)
|
117
|
+
else
|
118
|
+
super()
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ServiceOperation
|
4
|
+
# Callback logic for integration with delayed_job or similar
|
5
|
+
module Delay
|
6
|
+
def self.included(base)
|
7
|
+
base.extend ClassMethods
|
8
|
+
end
|
9
|
+
|
10
|
+
# Class Methods
|
11
|
+
module ClassMethods
|
12
|
+
def queue(input)
|
13
|
+
if respond_to?(:delay)
|
14
|
+
delay.call(input).id
|
15
|
+
else
|
16
|
+
call(input)
|
17
|
+
nil
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ServiceOperation
|
4
|
+
# ActiveModel compatable ErrorHandling
|
5
|
+
# depends on {Base#context} and {Errors}
|
6
|
+
module ErrorHandling
|
7
|
+
def errors
|
8
|
+
@errors ||= Errors.new
|
9
|
+
end
|
10
|
+
|
11
|
+
# @param [Failure, Hash, nil] error(s) merge into {#errors}
|
12
|
+
def fail!(error = {}, more = {})
|
13
|
+
return unless error
|
14
|
+
|
15
|
+
error = errors_from_error_code(error)
|
16
|
+
errors.coerced_merge error
|
17
|
+
|
18
|
+
more[:errors] ||= errors.merge(more[:errors] || {})
|
19
|
+
|
20
|
+
context.fail! more
|
21
|
+
end
|
22
|
+
|
23
|
+
# fail if there {#errors} Hash has any contents
|
24
|
+
def fail_if_errors!
|
25
|
+
invalid? && fail!
|
26
|
+
end
|
27
|
+
|
28
|
+
def invalid?
|
29
|
+
errors.any?
|
30
|
+
end
|
31
|
+
|
32
|
+
def valid?
|
33
|
+
!invalid?
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# convert :error into 'Error' based on lookup in hash {ERRORS}
|
39
|
+
def errors_from_error_code(error_code)
|
40
|
+
return error_code unless error_code.is_a?(Symbol)
|
41
|
+
|
42
|
+
return { error_code => send(error_code) } if attribute_exists?(error_code) # ?
|
43
|
+
|
44
|
+
context.error_code = error_code
|
45
|
+
errors = defined?(self.class::ERRORS) ? self.class::ERRORS : {}
|
46
|
+
Array(errors[error_code] || error_code.to_s)
|
47
|
+
end
|
48
|
+
|
49
|
+
#
|
50
|
+
# Method Missing
|
51
|
+
#
|
52
|
+
|
53
|
+
FAIL_IF_UNLESS_REGEXP = /^fail_(if|unless)_{0,1}([a-z_]*)!$/.freeze
|
54
|
+
|
55
|
+
def fail_conditional_object(conditional, object_method, object, errors = nil)
|
56
|
+
bool, errors = extract_bool_errors(object_method, object, errors)
|
57
|
+
|
58
|
+
case conditional
|
59
|
+
when 'if'
|
60
|
+
fail!(errors) if bool
|
61
|
+
when 'unless'
|
62
|
+
fail!(errors) unless bool
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def extract_bool_errors(object_method, object, errors)
|
67
|
+
# fail_if(object)
|
68
|
+
if object_method == '?'
|
69
|
+
[object, errors]
|
70
|
+
# fail_if(object.method?)
|
71
|
+
elsif object.respond_to?(object_method)
|
72
|
+
[object.send(object_method), errors || object]
|
73
|
+
# unknown
|
74
|
+
else
|
75
|
+
raise(NoMethodError, "#{object.class} does not respond to #{object_method}")
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def method_missing(method_name, *args, &block)
|
80
|
+
# {if/unless}, method_name
|
81
|
+
conditional, object_method = (method_name.to_s.match(FAIL_IF_UNLESS_REGEXP) || [])[1..2]
|
82
|
+
|
83
|
+
if conditional
|
84
|
+
fail_conditional_object(conditional, object_method + '?', *args)
|
85
|
+
else
|
86
|
+
super
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def respond_to_missing?(method_name, include_private = false)
|
91
|
+
method_name =~ FAIL_IF_UNLESS_REGEXP || super
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ServiceOperation
|
4
|
+
# Error object with minimal compatibility with ActiveModel style errors
|
5
|
+
class Errors < Hash
|
6
|
+
# @example
|
7
|
+
# add(:attr, 'error1', 'error2')
|
8
|
+
# @param [Symbol] attr to add error to
|
9
|
+
def add(attr, *args)
|
10
|
+
return self if args.empty?
|
11
|
+
|
12
|
+
self[attr] ||= []
|
13
|
+
self[attr] += args.flatten
|
14
|
+
|
15
|
+
self
|
16
|
+
end
|
17
|
+
|
18
|
+
# @param error_hash pass any type of error and it will be normalized to { attr: ['error'] }
|
19
|
+
def coerced_merge(error_hash)
|
20
|
+
ensure_error_hash(error_hash).each do |key, error|
|
21
|
+
object_to_array(error).each { |errors| add(key, *errors) }
|
22
|
+
end
|
23
|
+
|
24
|
+
self
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# @return [Hash] formatted { attribute: ['error'] }
|
30
|
+
def ensure_error_hash(object)
|
31
|
+
object = object.context.errors || object if object.is_a?(Failure)
|
32
|
+
object = object.errors.to_h if object.respond_to?(:errors)
|
33
|
+
object = object.messages.to_h if object.respond_to?(:messages)
|
34
|
+
object = { base: object } unless object.is_a?(Hash)
|
35
|
+
object
|
36
|
+
end
|
37
|
+
|
38
|
+
# convert ActiveRecord:Base / ActiveModel::Errors to an array
|
39
|
+
def object_to_array(errors)
|
40
|
+
errors = errors.message if errors.respond_to?(:message) # StandardError
|
41
|
+
errors = errors.errors if errors.respond_to?(:errors)
|
42
|
+
errors = errors.messages if errors.respond_to?(:full_messages)
|
43
|
+
Array(errors).compact
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ServiceOperation
|
4
|
+
# Simple hooks mechanism with inheritance.
|
5
|
+
module Hooks
|
6
|
+
def self.included(base)
|
7
|
+
base.class_eval do
|
8
|
+
extend ClassMethods
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# Hook Methods
|
13
|
+
module ClassMethods
|
14
|
+
# @example
|
15
|
+
# around do |op|
|
16
|
+
# result = nil
|
17
|
+
# ms = Benchmark.ms { result = op.call }
|
18
|
+
# puts "#{self.class.name} took #{ms}"
|
19
|
+
# result
|
20
|
+
# end
|
21
|
+
def around(*hooks, &block)
|
22
|
+
add_hooks(:around, hooks, block)
|
23
|
+
end
|
24
|
+
|
25
|
+
def before(*hooks, &block)
|
26
|
+
add_hooks(:before, hooks, block)
|
27
|
+
end
|
28
|
+
|
29
|
+
def after(*hooks, &block)
|
30
|
+
add_hooks(:after, hooks, block, :unshift)
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [Array<Symbol, Proc>]
|
34
|
+
def around_hooks
|
35
|
+
@around_hooks ||= initial_hooks(:around_hooks)
|
36
|
+
end
|
37
|
+
|
38
|
+
# @return [Array<Symbol, Proc>]
|
39
|
+
def before_hooks
|
40
|
+
@before_hooks ||= initial_hooks(:before_hooks)
|
41
|
+
end
|
42
|
+
|
43
|
+
# @return [Array<Symbol, Proc>]
|
44
|
+
def after_hooks
|
45
|
+
@after_hooks ||= initial_hooks(:after_hooks)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
# rubocop:disable Style/SafeNavigation
|
51
|
+
def initial_hooks(name)
|
52
|
+
superclass && superclass.respond_to?(name) ? superclass.send(name).dup : []
|
53
|
+
end
|
54
|
+
# rubocop:enable Style/SafeNavigation
|
55
|
+
|
56
|
+
def add_hooks(name, hooks, block, method = :push)
|
57
|
+
hooks << block if block
|
58
|
+
hooks.each { |hook| send("#{name}_hooks").send(method, hook) }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def with_hooks
|
65
|
+
run_around_hooks do
|
66
|
+
run_before_hooks
|
67
|
+
yield
|
68
|
+
run_after_hooks
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def run_around_hooks(&block)
|
73
|
+
self.class.around_hooks.reverse.inject(block) do |chain, hook|
|
74
|
+
proc { run_hook(hook, chain) }
|
75
|
+
end.call
|
76
|
+
end
|
77
|
+
|
78
|
+
def run_before_hooks
|
79
|
+
run_hooks(self.class.before_hooks)
|
80
|
+
end
|
81
|
+
|
82
|
+
def run_after_hooks
|
83
|
+
run_hooks(self.class.after_hooks)
|
84
|
+
end
|
85
|
+
|
86
|
+
def run_hooks(hooks)
|
87
|
+
hooks.each { |hook| run_hook(hook) }
|
88
|
+
end
|
89
|
+
|
90
|
+
# @param [Symbol, Proc] - name of a method defined in the operation or a block
|
91
|
+
def run_hook(hook, *args)
|
92
|
+
return if hook.class.name =~ /Delayed::Backend/ # prevent a clash with delayed_job gem.
|
93
|
+
|
94
|
+
hook.is_a?(Symbol) ? send(hook, *args) : instance_exec(*args, &hook)
|
95
|
+
end
|
96
|
+
|
97
|
+
# unimplemented method from previous project to define "before_call_do_this" style hooks.
|
98
|
+
# def run_before_call_methods
|
99
|
+
# private_methods.grep(/^before_call_/).map { |m| send(m) }
|
100
|
+
# end
|
101
|
+
end
|
102
|
+
end
|