service_operation 1.0.0

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