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 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceOperation
4
+ # StandardError for an operation
5
+ class Failure < StandardError
6
+ attr_reader :context
7
+
8
+ def initialize(context = nil)
9
+ @context = context
10
+ super
11
+ end
12
+ end
13
+ 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