stannum 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/DEVELOPMENT.md +105 -0
  5. data/LICENSE +22 -0
  6. data/README.md +1327 -0
  7. data/config/locales/en.rb +47 -0
  8. data/lib/stannum/attribute.rb +115 -0
  9. data/lib/stannum/constraint.rb +65 -0
  10. data/lib/stannum/constraints/absence.rb +42 -0
  11. data/lib/stannum/constraints/anything.rb +28 -0
  12. data/lib/stannum/constraints/base.rb +285 -0
  13. data/lib/stannum/constraints/boolean.rb +33 -0
  14. data/lib/stannum/constraints/delegator.rb +71 -0
  15. data/lib/stannum/constraints/enum.rb +64 -0
  16. data/lib/stannum/constraints/equality.rb +47 -0
  17. data/lib/stannum/constraints/hashes/extra_keys.rb +126 -0
  18. data/lib/stannum/constraints/hashes/indifferent_key.rb +74 -0
  19. data/lib/stannum/constraints/hashes.rb +11 -0
  20. data/lib/stannum/constraints/identity.rb +46 -0
  21. data/lib/stannum/constraints/nothing.rb +28 -0
  22. data/lib/stannum/constraints/presence.rb +42 -0
  23. data/lib/stannum/constraints/signature.rb +92 -0
  24. data/lib/stannum/constraints/signatures/map.rb +17 -0
  25. data/lib/stannum/constraints/signatures/tuple.rb +17 -0
  26. data/lib/stannum/constraints/signatures.rb +11 -0
  27. data/lib/stannum/constraints/tuples/extra_items.rb +84 -0
  28. data/lib/stannum/constraints/tuples.rb +10 -0
  29. data/lib/stannum/constraints/type.rb +113 -0
  30. data/lib/stannum/constraints/types/array_type.rb +148 -0
  31. data/lib/stannum/constraints/types/big_decimal_type.rb +16 -0
  32. data/lib/stannum/constraints/types/date_time_type.rb +16 -0
  33. data/lib/stannum/constraints/types/date_type.rb +16 -0
  34. data/lib/stannum/constraints/types/float_type.rb +14 -0
  35. data/lib/stannum/constraints/types/hash_type.rb +205 -0
  36. data/lib/stannum/constraints/types/hash_with_indifferent_keys.rb +21 -0
  37. data/lib/stannum/constraints/types/hash_with_string_keys.rb +21 -0
  38. data/lib/stannum/constraints/types/hash_with_symbol_keys.rb +21 -0
  39. data/lib/stannum/constraints/types/integer_type.rb +14 -0
  40. data/lib/stannum/constraints/types/nil_type.rb +20 -0
  41. data/lib/stannum/constraints/types/proc_type.rb +14 -0
  42. data/lib/stannum/constraints/types/string_type.rb +14 -0
  43. data/lib/stannum/constraints/types/symbol_type.rb +14 -0
  44. data/lib/stannum/constraints/types/time_type.rb +14 -0
  45. data/lib/stannum/constraints/types.rb +25 -0
  46. data/lib/stannum/constraints/union.rb +85 -0
  47. data/lib/stannum/constraints.rb +26 -0
  48. data/lib/stannum/contract.rb +243 -0
  49. data/lib/stannum/contracts/array_contract.rb +108 -0
  50. data/lib/stannum/contracts/base.rb +597 -0
  51. data/lib/stannum/contracts/builder.rb +72 -0
  52. data/lib/stannum/contracts/definition.rb +74 -0
  53. data/lib/stannum/contracts/hash_contract.rb +136 -0
  54. data/lib/stannum/contracts/indifferent_hash_contract.rb +78 -0
  55. data/lib/stannum/contracts/map_contract.rb +199 -0
  56. data/lib/stannum/contracts/parameters/arguments_contract.rb +185 -0
  57. data/lib/stannum/contracts/parameters/keywords_contract.rb +174 -0
  58. data/lib/stannum/contracts/parameters/signature_contract.rb +29 -0
  59. data/lib/stannum/contracts/parameters.rb +15 -0
  60. data/lib/stannum/contracts/parameters_contract.rb +530 -0
  61. data/lib/stannum/contracts/tuple_contract.rb +213 -0
  62. data/lib/stannum/contracts.rb +19 -0
  63. data/lib/stannum/errors.rb +730 -0
  64. data/lib/stannum/messages/default_strategy.rb +124 -0
  65. data/lib/stannum/messages.rb +25 -0
  66. data/lib/stannum/parameter_validation.rb +216 -0
  67. data/lib/stannum/rspec/match_errors.rb +17 -0
  68. data/lib/stannum/rspec/match_errors_matcher.rb +93 -0
  69. data/lib/stannum/rspec/validate_parameter.rb +23 -0
  70. data/lib/stannum/rspec/validate_parameter_matcher.rb +506 -0
  71. data/lib/stannum/rspec.rb +8 -0
  72. data/lib/stannum/schema.rb +131 -0
  73. data/lib/stannum/struct.rb +444 -0
  74. data/lib/stannum/support/coercion.rb +114 -0
  75. data/lib/stannum/support/optional.rb +69 -0
  76. data/lib/stannum/support.rb +8 -0
  77. data/lib/stannum/version.rb +57 -0
  78. data/lib/stannum.rb +27 -0
  79. metadata +216 -0
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ require 'sleeping_king_studios/tools/toolbelt'
6
+
7
+ require 'stannum/messages'
8
+
9
+ module Stannum::Messages
10
+ # Strategy to generate error messages from gem configuration.
11
+ class DefaultStrategy
12
+ # @param configuration [Hash{Symbol, Object}] The configured messages.
13
+ # @param load_path [Array<String>] The filenames for the configuration
14
+ # file(s).
15
+ def initialize(configuration: nil, load_path: nil)
16
+ @load_path = load_path.nil? ? [default_filename] : Array(load_path)
17
+ @configuration = configuration
18
+ end
19
+
20
+ # @return [Array<String>] the filenames for the configuration file(s).
21
+ attr_reader :load_path
22
+
23
+ # @param error_type [String] The qualified path to the configured error
24
+ # message.
25
+ # @param options [Hash] Additional properties to interpolate or to pass to
26
+ # the message proc.
27
+ def call(error_type, **options)
28
+ unless error_type.is_a?(String) || error_type.is_a?(Symbol)
29
+ raise ArgumentError, 'error type must be a String or Symbol'
30
+ end
31
+
32
+ message = generate_message(error_type, options)
33
+
34
+ interpolate_message(message, options)
35
+ end
36
+
37
+ # Reloads the configuration from the configured load_path.
38
+ #
39
+ # This can be useful when the load_path is updated after creating the
40
+ # strategy, such as in an initializer for another gem.
41
+ #
42
+ # @return [DefaultStrategy] the strategy.
43
+ def reload_configuration!
44
+ @configuration = load_configuration
45
+
46
+ self
47
+ end
48
+
49
+ private
50
+
51
+ def configuration
52
+ @configuration ||= load_configuration
53
+ end
54
+
55
+ def deep_merge(source, target)
56
+ hsh = tools.hash_tools.deep_dup(source)
57
+
58
+ target.each do |key, value|
59
+ hsh[key] = value.is_a?(Hash) ? deep_merge(hsh[key] || {}, value) : value
60
+ end
61
+
62
+ hsh
63
+ end
64
+
65
+ def default_filename
66
+ File.join(Stannum::Messages.locales_path, 'en.rb')
67
+ end
68
+
69
+ def generate_message(error_type, options)
70
+ path = error_type.to_s.split('.').map(&:intern)
71
+ path.unshift(:en)
72
+
73
+ message = configuration.dig(*path)
74
+
75
+ return message if message.is_a?(String)
76
+
77
+ return message.call(error_type, options) if message.is_a?(Proc)
78
+
79
+ return "no message defined for #{error_type.inspect}" if message.nil?
80
+
81
+ "configuration is a namespace at #{error_type}"
82
+ end
83
+
84
+ def interpolate_message(message, options)
85
+ message.gsub(/%{\w+}/) do |match|
86
+ key = match[2..-2].intern
87
+
88
+ options.fetch(key, match)
89
+ end
90
+ end
91
+
92
+ def load_configuration
93
+ load_path.reduce({}) do |config, filename|
94
+ deep_merge(config, read_configuration(filename))
95
+ end
96
+ end
97
+
98
+ def read_configuration(filename)
99
+ case File.extname(filename)
100
+ when '.rb'
101
+ read_ruby_file(filename)
102
+ when '.yml'
103
+ read_yaml_file(filename)
104
+ else
105
+ raise "unable to load configuration file #{filename} with extension" \
106
+ " #{File.extname(filename)}"
107
+ end
108
+ end
109
+
110
+ def read_ruby_file(filename)
111
+ eval(File.read(filename), binding, filename) # rubocop:disable Security/Eval
112
+ end
113
+
114
+ def read_yaml_file(filename)
115
+ tools.hash_tools.convert_keys_to_symbols(
116
+ YAML.safe_load(File.read(filename))
117
+ )
118
+ end
119
+
120
+ def tools
121
+ SleepingKingStudios::Tools::Toolbelt.instance
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum'
4
+
5
+ module Stannum
6
+ # Namespace for generating messages for Stannum::Errors.
7
+ module Messages
8
+ autoload :DefaultStrategy, 'stannum/messages/default_strategy'
9
+
10
+ # @return [String] the absolute path to the configured locales.
11
+ def self.locales_path
12
+ File.join(Stannum.gem_path, 'config', 'locales')
13
+ end
14
+
15
+ # @return [#call] the configured strategy for generating messages.
16
+ def self.strategy
17
+ @strategy ||= DefaultStrategy.new
18
+ end
19
+
20
+ # @param strategy [#call] The strategy to use to generate error messages.
21
+ def self.strategy=(strategy)
22
+ @strategy = strategy
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum'
4
+
5
+ module Stannum
6
+ # Provides a DSL for validating method parameters.
7
+ #
8
+ # Use the .validate_parameters method to define parameter validation for an
9
+ # instance method of the class or module.
10
+ #
11
+ # Ruby does not distinguish between an explicit nil versus an undefined value
12
+ # in an Array or Hash, but does distinguish between a nil value and a missing
13
+ # parameter. Be careful when validating methods with optional or default
14
+ # arguments or keywords:
15
+ #
16
+ # * If the actual value can be nil, or if the default parameter is nil, then
17
+ # use the optional: true option. This will match an empty arguments list, or
18
+ # an arguments list with nil as the first value:
19
+ #
20
+ # def method_with_optional_argument(name: nil)
21
+ # @name = name || 'Alan Bradley'
22
+ # end
23
+ #
24
+ # validate_parameters(:method_with_optional_argument) do
25
+ # argument :name, optional: true
26
+ # end
27
+ #
28
+ # * If the default parameter is any other value, then use the default: true
29
+ # option. This will match an empty arguments list, but not an arguments list
30
+ # with nil as the first value:
31
+ #
32
+ # def method_with_default_argument(role: 'User')
33
+ # @role = role
34
+ # end
35
+ #
36
+ # validate_parameters(:method_with_default_argument) do
37
+ # argument :role, default: true
38
+ # end
39
+ #
40
+ # @example Validating Parameters
41
+ # class PerformAction
42
+ # include Stannum::ParameterValidation
43
+ #
44
+ # def perform(action, record_class = nil, user:, role: 'User')
45
+ # end
46
+ #
47
+ # validate_parameters(:perform) do
48
+ # argument :action, Symbol
49
+ # argument :record_class, Class, optional: true
50
+ # keyword :role, String, default: true
51
+ # keyword :user, Stannum::Constraints::Type.new(User)
52
+ # end
53
+ # end
54
+ #
55
+ # @example Validating Class Methods
56
+ # module Authorization
57
+ # extend Stannum::ParameterValidation
58
+ #
59
+ # class << self
60
+ # def authorize_user(user, role: 'User')
61
+ # end
62
+ #
63
+ # validate_parameters(:authorize_user) do
64
+ # argument :user, User
65
+ # argument :role, String, default: true
66
+ # end
67
+ # end
68
+ # end
69
+ #
70
+ # @see Stannum::Contracts::ParametersContract.
71
+ module ParameterValidation
72
+ # @api private
73
+ #
74
+ # Value used to indicate a successful validation of the parameters.
75
+ VALIDATION_SUCCESS = Object.new.freeze
76
+
77
+ # @api private
78
+ #
79
+ # Base class for modules that handle tracking validated methods.
80
+ class MethodValidations < Module
81
+ def initialize
82
+ super
83
+
84
+ @contracts = {}
85
+ end
86
+
87
+ # @private
88
+ def add_contract(method_name, contract)
89
+ @contracts[method_name] = contract
90
+ end
91
+
92
+ # @return [Hash] the validation contracts defined for the class.
93
+ def contracts
94
+ ancestors
95
+ .select do |ancestor|
96
+ ancestor.is_a? Stannum::ParameterValidation::MethodValidations
97
+ end
98
+ .map(&:own_contracts)
99
+ .reduce(:merge)
100
+ end
101
+
102
+ # @private
103
+ def own_contracts
104
+ @contracts
105
+ end
106
+ end
107
+
108
+ # Defines a DSL for validating method parameters.
109
+ #
110
+ # @see ParameterValidation.
111
+ module ClassMethods
112
+ # rubocop:disable Metrics/MethodLength
113
+
114
+ # Creates a validation contract and wraps the named method.
115
+ #
116
+ # The provided block is used to create a ParametersContract, and supports
117
+ # the same DSL used to define one.
118
+ #
119
+ # @see Stannum::Contracts::ParametersContract
120
+ def validate_parameters(method_name, &validations)
121
+ method_name = method_name.intern
122
+ contract = Stannum::Contracts::ParametersContract.new(&validations)
123
+
124
+ self::MethodValidations.add_contract(method_name, contract)
125
+
126
+ self::MethodValidations.define_method(method_name) \
127
+ do |*arguments, **keywords, &block|
128
+ result = match_parameters_to_contract(
129
+ arguments: arguments,
130
+ block: block,
131
+ contract: contract,
132
+ keywords: keywords,
133
+ method_name: method_name
134
+ )
135
+
136
+ return result unless result == VALIDATION_SUCCESS
137
+
138
+ if keywords.empty?
139
+ super(*arguments, &block)
140
+ else
141
+ super(*arguments, **keywords, &block)
142
+ end
143
+ end
144
+ end
145
+ # rubocop:enable Metrics/MethodLength
146
+
147
+ private
148
+
149
+ def inherited(subclass)
150
+ super
151
+
152
+ Stannum::ParameterValidation.add_method_validations(subclass)
153
+
154
+ subclass::MethodValidations.include(self::MethodValidations)
155
+ end
156
+ end
157
+
158
+ class << self
159
+ # @private
160
+ def add_method_validations(other)
161
+ other.extend(ClassMethods)
162
+
163
+ validations = MethodValidations.new
164
+
165
+ other.const_set(:MethodValidations, validations)
166
+ other.prepend(validations)
167
+ end
168
+
169
+ private
170
+
171
+ def extended(other)
172
+ super
173
+
174
+ add_method_validations(other.singleton_class)
175
+ end
176
+
177
+ def included(other)
178
+ super
179
+
180
+ add_method_validations(other)
181
+ end
182
+ end
183
+
184
+ private
185
+
186
+ def handle_invalid_parameters(errors:, method_name:)
187
+ error_message = "invalid parameters for ##{method_name}"
188
+ error_message += ": #{errors.summary}" unless errors.empty?
189
+
190
+ raise ArgumentError, error_message
191
+ end
192
+
193
+ def match_parameters_to_contract( # rubocop:disable Metrics/MethodLength
194
+ contract:,
195
+ method_name:,
196
+ arguments: [],
197
+ block: nil,
198
+ keywords: {}
199
+ )
200
+ match, errors = contract.match(
201
+ {
202
+ arguments: arguments,
203
+ keywords: keywords,
204
+ block: block
205
+ }
206
+ )
207
+
208
+ return VALIDATION_SUCCESS if match
209
+
210
+ handle_invalid_parameters(
211
+ errors: errors,
212
+ method_name: method_name
213
+ )
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/rspec/match_errors_matcher'
4
+
5
+ module Stannum::RSpec
6
+ # Namespace for custom RSpec matcher macros.
7
+ module Matchers
8
+ # Builds a MatchErrorsMatcher.
9
+ #
10
+ # @param expected [Stannum::Errors] The expected errors.
11
+ #
12
+ # @return [Stannum::RSpec::MatchErrorsMatcher] the matcher.
13
+ def match_errors(expected)
14
+ Stannum::RSpec::MatchErrorsMatcher.new(expected)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'rspec/sleeping_king_studios/matchers/core/deep_matcher'
5
+ rescue NameError
6
+ # :nocov:
7
+ Kernel.warn 'WARNING: RSpec::SleepingKingStudios is a dependency for using' \
8
+ ' the MatchErrorsMatcher or the #match_errors method.'
9
+ # :nocov:
10
+ end
11
+
12
+ require 'stannum/errors'
13
+ require 'stannum/rspec'
14
+
15
+ module Stannum::RSpec
16
+ # Asserts that the expected and actual errors are equal.
17
+ class MatchErrorsMatcher
18
+ # @param expected [Stannum::Errors] The expected errors.
19
+ def initialize(expected)
20
+ @expected = expected.to_a
21
+ end
22
+
23
+ # @return [String] a short description of the matcher and expected
24
+ # properties.
25
+ def description
26
+ 'match the expected errors'
27
+ end
28
+
29
+ # Checks that the given errors do not match the expected errors.
30
+ def does_not_match?(actual)
31
+ @actual = actual.is_a?(Stannum::Errors) ? actual.to_a : actual
32
+
33
+ errors? && equality_matcher.does_not_match?(@actual)
34
+ rescue NoMethodError
35
+ # :nocov:
36
+ errors? && !equality_matcher.matches?(@actual)
37
+ # :nocov:
38
+ end
39
+
40
+ # @return [String] a summary message describing a failed expectation.
41
+ def failure_message
42
+ unless errors?
43
+ return 'expected the errors to match the expected errors, but the' \
44
+ ' object is not an array or Errors object'
45
+ end
46
+
47
+ equality_matcher.failure_message
48
+ end
49
+
50
+ # @return [String] a summary message describing a failed negated
51
+ # expectation.
52
+ def failure_message_when_negated
53
+ unless errors?
54
+ return 'expected the errors not to match the expected errors, but the' \
55
+ ' object is not an array or Errors object'
56
+ end
57
+
58
+ equality_matcher.failure_message_when_negated
59
+ end
60
+
61
+ # Checks that the given errors match the expected errors.
62
+ #
63
+ # Returns false if the object is not a Stannum::Errors instance or an Array.
64
+ # Otherwise, it converts the expected and actual errors to arrays and
65
+ # performs a deep match.
66
+ #
67
+ # @param actual [Object] The actual object to match.
68
+ #
69
+ # @return [Boolean] true if the actual errors match the expected errors.
70
+ def matches?(actual)
71
+ @actual = actual.is_a?(Stannum::Errors) ? actual.to_a : actual
72
+
73
+ errors? && equality_matcher.matches?(@actual)
74
+ end
75
+
76
+ private
77
+
78
+ def equality_matcher
79
+ @equality_matcher ||=
80
+ RSpec::SleepingKingStudios::Matchers::Core::DeepMatcher
81
+ .new(@expected)
82
+ rescue NameError
83
+ # :nocov:
84
+ @equality_matcher ||=
85
+ RSpec::Matchers::BuiltIn::Eq.new(@expected_errors.to_a)
86
+ # :nocov:
87
+ end
88
+
89
+ def errors?
90
+ @actual.is_a?(Stannum::Errors) || @actual.is_a?(Array)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/rspec/validate_parameter_matcher'
4
+
5
+ module Stannum::RSpec
6
+ # Namespace for custom RSpec matcher macros.
7
+ module Matchers
8
+ # Builds a ValidateParameterMatcher.
9
+ #
10
+ # @param method_name [String, Symbol] The name of the method with validated
11
+ # parameters.
12
+ # @param parameter_name [String, Symbol] The name of the validated method
13
+ # parameter.
14
+ #
15
+ # @return [Stannum::RSpec::ValidateParameterMatcher] the matcher.
16
+ def validate_parameter(method_name, parameter_name)
17
+ Stannum::RSpec::ValidateParameterMatcher.new(
18
+ method_name: method_name,
19
+ parameter_name: parameter_name
20
+ )
21
+ end
22
+ end
23
+ end