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.
@@ -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