service_operation 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|