service_operation 1.0.0

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