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
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rubocop:disable Style/SafeNavigation
|
4
|
+
require 'service_operation/params/types'
|
5
|
+
|
6
|
+
module ServiceOperation
|
7
|
+
# Params depends on {Hooks}
|
8
|
+
module Params
|
9
|
+
autoload :Attribute, 'service_operation/params/attribute'
|
10
|
+
autoload :DSL, 'service_operation/params/dsl'
|
11
|
+
|
12
|
+
def self.included(base)
|
13
|
+
base.extend ClassMethods
|
14
|
+
base.send :include, InstanceMethods
|
15
|
+
end
|
16
|
+
|
17
|
+
# ClassMethods
|
18
|
+
module ClassMethods
|
19
|
+
# @example
|
20
|
+
# params do
|
21
|
+
# param1, :integer, coerce: true
|
22
|
+
# end
|
23
|
+
def params(&block)
|
24
|
+
@params ||= superclass && superclass.respond_to?(:params) ? superclass.params.dup : []
|
25
|
+
|
26
|
+
if block_given?
|
27
|
+
@params += Params::DSL.run(&block)
|
28
|
+
define_params_hooks
|
29
|
+
end
|
30
|
+
|
31
|
+
@params = @params.reverse.uniq(&:name).reverse
|
32
|
+
@params
|
33
|
+
end
|
34
|
+
|
35
|
+
alias input params
|
36
|
+
|
37
|
+
# @example
|
38
|
+
# returns do
|
39
|
+
# result, [:string]
|
40
|
+
# log_data, [:string], optional: true
|
41
|
+
# end
|
42
|
+
def returns(&block)
|
43
|
+
@returns ||= superclass && superclass.respond_to?(:returns) ? superclass.returns.dup : []
|
44
|
+
|
45
|
+
@returns += Params::DSL.run(&block) if block_given?
|
46
|
+
|
47
|
+
@returns = @returns.reverse.uniq(&:name).reverse
|
48
|
+
@returns
|
49
|
+
end
|
50
|
+
|
51
|
+
def attributes
|
52
|
+
(returns + params).uniq(&:name) # returns first to preserve log: option
|
53
|
+
end
|
54
|
+
|
55
|
+
# should only be called by Instance after all attributes have been defined
|
56
|
+
def attribute_names
|
57
|
+
@attribute_names ||= attributes.map(&:name)
|
58
|
+
end
|
59
|
+
|
60
|
+
alias output returns
|
61
|
+
|
62
|
+
def remove_params(*args)
|
63
|
+
params.delete_if { |a| args.include?(a.name) }
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def define_params_hooks
|
69
|
+
return if @defined_params_hooks
|
70
|
+
|
71
|
+
before :validate_params
|
72
|
+
after :validate_returns
|
73
|
+
@defined_params_hooks = true
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# InstanceMethods
|
78
|
+
module InstanceMethods
|
79
|
+
private
|
80
|
+
|
81
|
+
# coerces param and adds an error if it fails to validate type
|
82
|
+
def validate_params
|
83
|
+
validate_attributes(self.class.params, coerce: true)
|
84
|
+
end
|
85
|
+
|
86
|
+
def validate_returns
|
87
|
+
validate_attributes(self.class.returns)
|
88
|
+
end
|
89
|
+
|
90
|
+
def validate_attributes(attributes, coerce: false)
|
91
|
+
attributes.each do |attr|
|
92
|
+
value = attr.optional ? context[attr.name] : send(attr.name)
|
93
|
+
|
94
|
+
context[attr.name] = value = attr.from(value, self.class) if coerce
|
95
|
+
|
96
|
+
if error = attr.error(value)
|
97
|
+
errors.add(attr.name, error)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
context.fail!(errors: errors) if errors.any?
|
102
|
+
end
|
103
|
+
|
104
|
+
#
|
105
|
+
# Method Missing to delegate to params/context
|
106
|
+
#
|
107
|
+
|
108
|
+
# delegate to context if calling an explicit param
|
109
|
+
def method_missing(method_name, *args, &block)
|
110
|
+
method_name_without_q = method_name.to_s.delete('?').to_sym
|
111
|
+
if attribute_exists?(method_name_without_q)
|
112
|
+
context.send(method_name_without_q, *args, &block)
|
113
|
+
else
|
114
|
+
super
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def respond_to_missing?(method_name, include_private = false)
|
119
|
+
attribute_exists?(method_name) || super
|
120
|
+
end
|
121
|
+
|
122
|
+
def attribute_exists?(method_name)
|
123
|
+
self.class.attribute_names.include?(method_name)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# rubocop:enable Style/SafeNavigation
|
@@ -0,0 +1,164 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'service_operation/params'
|
4
|
+
|
5
|
+
module ServiceOperation
|
6
|
+
KEYWORDS = [
|
7
|
+
:base, :errors, :error_code, :status_code
|
8
|
+
].freeze
|
9
|
+
|
10
|
+
module Params
|
11
|
+
#
|
12
|
+
# Represents a single attribute of a value class
|
13
|
+
#
|
14
|
+
class Attribute
|
15
|
+
OPTIONS = [:name, :validator, :coercer, :default, :log, :optional].freeze
|
16
|
+
|
17
|
+
# Types of validation classes that can be expanded
|
18
|
+
EXPANDABLE_VALIDATORS = %w[Array Symbol String].freeze
|
19
|
+
|
20
|
+
# Typical coercions from web/string parameters
|
21
|
+
PARAM_COERCIONS = {
|
22
|
+
date: ->(d) { d.is_a?(String) ? Date.parse(d) : d },
|
23
|
+
integer: ->(o) { o && o != '' ? o.to_i : nil },
|
24
|
+
string: ->(o) { o && o != '' ? o.to_s : nil }
|
25
|
+
}.freeze
|
26
|
+
|
27
|
+
COERCIONS = {
|
28
|
+
'Integer' => PARAM_COERCIONS[:integer],
|
29
|
+
'String' => PARAM_COERCIONS[:string],
|
30
|
+
'EnumerableOf(Integer)' => ->(o) { Array(o).map(&:to_i) },
|
31
|
+
'EnumerableOf(String)' => ->(o) { Array(o).map(&:to_s) },
|
32
|
+
|
33
|
+
json_api_page: lambda do |o|
|
34
|
+
h = Hash(o)
|
35
|
+
h[:size] = h[:size].to_i if h && h[:size].present?
|
36
|
+
h
|
37
|
+
end
|
38
|
+
}.freeze
|
39
|
+
|
40
|
+
#
|
41
|
+
# Class Methods
|
42
|
+
#
|
43
|
+
|
44
|
+
class << self
|
45
|
+
def define(*args)
|
46
|
+
options = extract_options(args)
|
47
|
+
name, validator = args
|
48
|
+
validator ||= Anything
|
49
|
+
|
50
|
+
raise "#{name.inspect} is a keyword" if KEYWORDS.include?(name)
|
51
|
+
|
52
|
+
if EXPANDABLE_VALIDATORS.include?(validator.class.name)
|
53
|
+
validator = expand_validator(validator)
|
54
|
+
options[:coerce] = COERCIONS[validator.name] || true if options[:coerce].nil?
|
55
|
+
end
|
56
|
+
|
57
|
+
# options[:coerce] = COERCIONS[validator.name] || options[:coerce]
|
58
|
+
options[:coercer] = options.delete(:coerce)
|
59
|
+
|
60
|
+
new options.merge(name: name, validator: validator)
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# activesupport/lib/active_support/inflector/methods.rb
|
66
|
+
# rubocop:disable all
|
67
|
+
def camelize(string, uppercase_first_letter = true)
|
68
|
+
if uppercase_first_letter
|
69
|
+
string = string.sub(/^[a-z\d]*/) { $&.capitalize }
|
70
|
+
else
|
71
|
+
string = string.sub(/^(?:(?=\b|[A-Z_])|\w)/) { $&.downcase }
|
72
|
+
end
|
73
|
+
string.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }.gsub('/', '::')
|
74
|
+
end
|
75
|
+
# rubocop:enable all
|
76
|
+
|
77
|
+
def expand_validator(validator)
|
78
|
+
case validator
|
79
|
+
when Array
|
80
|
+
EnumerableOf.new(*validator.map { |v| expand_validator(v) })
|
81
|
+
when Symbol, String
|
82
|
+
validator = 'bool' if validator.to_s == 'boolean'
|
83
|
+
Params.const_get camelize(validator.to_s)
|
84
|
+
else
|
85
|
+
validator
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def extract_options(args)
|
90
|
+
args.last.is_a?(Hash) ? args.pop : {}
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
#
|
95
|
+
# Instance Methods
|
96
|
+
#
|
97
|
+
|
98
|
+
attr_reader :name, :validator, :coercer, :default, :log, :optional, :options
|
99
|
+
|
100
|
+
def initialize(options = {})
|
101
|
+
@name = options[:name].to_sym
|
102
|
+
@validator = options[:validator]
|
103
|
+
@coercer = options[:coercer]
|
104
|
+
@default = options[:default]
|
105
|
+
@log = options.fetch(:log) { true }
|
106
|
+
@optional = options.fetch(:optional) { false }
|
107
|
+
@options = options.reject { |k, _v| OPTIONS.include?(k) }
|
108
|
+
|
109
|
+
freeze
|
110
|
+
end
|
111
|
+
|
112
|
+
def ==(other)
|
113
|
+
name == other.name
|
114
|
+
end
|
115
|
+
|
116
|
+
def from(raw_value, klass = nil)
|
117
|
+
raw_value = (default.respond_to?(:call) ? default.call : default) if raw_value.nil?
|
118
|
+
coerce(raw_value, klass)
|
119
|
+
end
|
120
|
+
|
121
|
+
def error(value)
|
122
|
+
return if validate?(value)
|
123
|
+
|
124
|
+
if required? && value.nil?
|
125
|
+
"can't be blank"
|
126
|
+
else
|
127
|
+
"must be typecast '#{validator.name}'"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def optional?
|
132
|
+
optional == true
|
133
|
+
end
|
134
|
+
|
135
|
+
def required?
|
136
|
+
!optional
|
137
|
+
end
|
138
|
+
|
139
|
+
def validate?(value)
|
140
|
+
# special exception to prevent Object === nil from validating
|
141
|
+
return false if value.nil? && !optional
|
142
|
+
|
143
|
+
optional || validator === value # rubocop:disable Style/CaseEquality
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
|
148
|
+
def coerce(value, klass)
|
149
|
+
return value unless coercer && !value.nil? # coercion not enabled or value was nil
|
150
|
+
return klass.public_send(coercion_method, value) if klass.respond_to?(coercion_method)
|
151
|
+
|
152
|
+
if coercer.respond_to?(:call)
|
153
|
+
coercer.call value
|
154
|
+
else
|
155
|
+
value
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def coercion_method
|
160
|
+
"coerce_#{name}"
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'service_operation/params'
|
4
|
+
|
5
|
+
module ServiceOperation
|
6
|
+
module Params
|
7
|
+
# Build {Params::Attribute} DSL
|
8
|
+
class DSL
|
9
|
+
# @yield to the block containing the DSL
|
10
|
+
# @return [Array<Attribute>]
|
11
|
+
def self.run(&block)
|
12
|
+
dsl = new
|
13
|
+
dsl.instance_eval(&block)
|
14
|
+
dsl.instance_variable_get('@attributes').freeze
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@attributes = []
|
19
|
+
end
|
20
|
+
|
21
|
+
# rubocop:disable Naming/MethodName
|
22
|
+
|
23
|
+
def Any(*subvalidators)
|
24
|
+
Any.new(subvalidators)
|
25
|
+
end
|
26
|
+
|
27
|
+
def Anything
|
28
|
+
Anything
|
29
|
+
end
|
30
|
+
|
31
|
+
def ArrayOf(element_validator)
|
32
|
+
ArrayOf.new(element_validator)
|
33
|
+
end
|
34
|
+
|
35
|
+
def Bool
|
36
|
+
Bool
|
37
|
+
end
|
38
|
+
|
39
|
+
def EnumerableOf(element_validator)
|
40
|
+
EnumerableOf.new(element_validator)
|
41
|
+
end
|
42
|
+
|
43
|
+
# rubocop:enable Naming/MethodName
|
44
|
+
|
45
|
+
# @todo: move
|
46
|
+
def _query_params(default_sort: 'id')
|
47
|
+
id :integer, optional: true
|
48
|
+
ids [:integer], optional: true
|
49
|
+
|
50
|
+
filter :hash, optional: true,
|
51
|
+
coerce: ->(f) { f.is_a?(Hash) ? f : Array(f).map { [f, nil] }.to_h }
|
52
|
+
|
53
|
+
includes [:string], optional: true
|
54
|
+
page :hash, optional: true, coerce: :json_api_page
|
55
|
+
sort :string, optional: true, default: default_sort
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def def_attr(*args)
|
61
|
+
@attributes << Attribute.define(*args)
|
62
|
+
end
|
63
|
+
|
64
|
+
def method_missing(name, *args)
|
65
|
+
if respond_to_missing?(name)
|
66
|
+
def_attr(name, *args)
|
67
|
+
else
|
68
|
+
super
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# any lowercase method name becomes an attribute
|
73
|
+
def respond_to_missing?(method_name, _include_private = nil)
|
74
|
+
first_letter = method_name.to_s.each_char.first
|
75
|
+
first_letter.eql?(first_letter.downcase)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rubocop:disable Style/CaseEquality
|
4
|
+
|
5
|
+
require 'service_operation/params'
|
6
|
+
|
7
|
+
module ServiceOperation
|
8
|
+
module Params
|
9
|
+
# Matches true or false
|
10
|
+
module Bool
|
11
|
+
# @return [Boolean]
|
12
|
+
def self.===(other)
|
13
|
+
true.equal?(other) || false.equal?(other)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Matches any value
|
18
|
+
module Anything
|
19
|
+
# @return [true]
|
20
|
+
def self.===(_other)
|
21
|
+
true
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# @abstract for Enumerator based types
|
26
|
+
class EnumType
|
27
|
+
def ==(other)
|
28
|
+
other.is_a?(self.class) && other.inspect == inspect
|
29
|
+
end
|
30
|
+
|
31
|
+
# @abstract
|
32
|
+
def initialize(*_args)
|
33
|
+
freeze
|
34
|
+
end
|
35
|
+
|
36
|
+
def inspect
|
37
|
+
"<#{name}>"
|
38
|
+
end
|
39
|
+
|
40
|
+
def name
|
41
|
+
type.name
|
42
|
+
end
|
43
|
+
|
44
|
+
# @abstract
|
45
|
+
def type
|
46
|
+
raise('define in sub class')
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Matches any sub type
|
51
|
+
class Any < EnumType
|
52
|
+
attr_reader :sub_types
|
53
|
+
|
54
|
+
def initialize(sub_types)
|
55
|
+
@sub_types = Array(sub_types)
|
56
|
+
super
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return [Boolean]
|
60
|
+
def ===(other)
|
61
|
+
sub_types.any? { |sv| sv === other }
|
62
|
+
end
|
63
|
+
|
64
|
+
# @return [String] representation of class and its sub classes
|
65
|
+
def name
|
66
|
+
"Any(#{sub_types.map(&:name).join(', ')})"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Matches an Enumerable with specific sub types
|
71
|
+
# @example EnumerableOf.new(String, Integer)
|
72
|
+
class EnumerableOf < EnumType
|
73
|
+
attr_reader :element_type
|
74
|
+
|
75
|
+
def initialize(*args)
|
76
|
+
@element_type = args.length == 1 ? args.first : Any.new(args)
|
77
|
+
|
78
|
+
super
|
79
|
+
end
|
80
|
+
|
81
|
+
# @return [Boolean]
|
82
|
+
def ===(other)
|
83
|
+
type === other && other.all? { |element| element_type === element }
|
84
|
+
end
|
85
|
+
|
86
|
+
def name
|
87
|
+
"#{super}Of(#{element_type.name})"
|
88
|
+
end
|
89
|
+
|
90
|
+
def type
|
91
|
+
Enumerable
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Matches an Array with specific sub types
|
96
|
+
# @example ArrayOf.new(String)
|
97
|
+
# @example ArrayOf.new(String, Integer)
|
98
|
+
class ArrayOf < EnumerableOf
|
99
|
+
def type
|
100
|
+
Array
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# rubocop:enable Style/CaseEquality
|