stannum 0.1.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.
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