axn 0.1.0.pre.alpha.2.5 → 0.1.0.pre.alpha.2.5.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad00a162e22de940a1c6e4a4de1f75533fb1aa5440c6cf8808f81a964e2457d4
4
- data.tar.gz: dfb840d11657f80f92d3e1aa19bef3cb64b1d28a84f70f0dd1b92c5726f4fd7f
3
+ metadata.gz: eb044bed464d8840289e69cb631ff47ebc44999ac7e6327fc3b69dd62a37a6af
4
+ data.tar.gz: 0f0db9209a545ac84b88df56aaeaea431e6094385c433775f8d3f6501d84e870
5
5
  SHA512:
6
- metadata.gz: 6704196ea530709faf393c51886f0ab4ed34fd5f9654cf4d086d08ba30a455140297bf695d29d0e7a15376b8b3064c9093e882f74f84139e21dc2bdc1e122298
7
- data.tar.gz: e319dcdf830b8b49fafa2db5f8461b951bf96db8a57b1b1172075a69db2f1eacd4674807526480d7f56a5fbe03265bf549f755359986a02805a5e982f0c769d1
6
+ metadata.gz: df227aa350f02e53d8141fec5bd6855ed31c1da64ec9b6567ce3906b86fce2bc6b1b4bc24a38042b565da8c7bee6df0ed95fd0bd1ab5afa8b722397ce10b5f09
7
+ data.tar.gz: 35b54feff70f62f1b18e9dbe9b0ac066ee6c1b13118f871e9b4bf36a8949661410886b4ac97e019f5c3a89c5f3082c9cebe0008a12c5fc810cbea418ef1c99bc
data/.rubocop.yml CHANGED
@@ -37,22 +37,22 @@ Metrics/MethodLength:
37
37
  Max: 60
38
38
 
39
39
  Metrics/PerceivedComplexity:
40
- Max: 15
40
+ Max: 16
41
41
 
42
42
  Metrics/AbcSize:
43
43
  Max: 60
44
44
 
45
45
  Metrics/CyclomaticComplexity:
46
- Max: 14
46
+ Max: 16
47
47
 
48
48
  Lint/EmptyBlock:
49
49
  Enabled: false
50
50
 
51
51
  Naming/MethodParameterName:
52
- AllowedNames: e
52
+ AllowedNames: e, on, id
53
53
 
54
54
  Metrics/ParameterLists:
55
- Max: 7
55
+ Max: 9
56
56
 
57
57
  Layout/LineLength:
58
58
  Max: 160
data/CHANGELOG.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## UNRELEASED
4
- * N/A
3
+ ## 0.1.0-alpha.2.5.1.1
4
+ * [BUGFIX] TypeValidator must handle anonymous classes when determining if given argument is an RSpec mock
5
+
6
+ ## 0.1.0-alpha.2.5.1
7
+ * Added new `model` validator for expectations
8
+ * [FEAT] Extended `expects` with the `on:` key to allow declaring nested data shapes/validations
5
9
 
6
10
  ## 0.1.0-alpha.2.5
7
11
  * Support blank exposures for `Action::Result.ok`
@@ -1,3 +1,7 @@
1
+ ---
2
+ outline: deep
3
+ ---
4
+
1
5
  # Class Methods
2
6
 
3
7
  ## `.expects` and `.exposes`
@@ -22,7 +26,7 @@ Both `expects` and `exposes` support the same core options:
22
26
  While we _support_ complex interface validations, in practice you usually just want a `type`, if anything. Remember this is your validation about how the action is called, _not_ pretty user-facing errors (there's [a different pattern for that](/recipes/validating-user-input)).
23
27
  :::
24
28
 
25
- In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support two additional custom validators:
29
+ In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support three additional custom validators:
26
30
  * `type: Foo` - fails unless the provided value `.is_a?(Foo)`
27
31
  * Edge case: use `type: :boolean` to handle a boolean field (since ruby doesn't have a Boolean class to pass in directly)
28
32
  * Edge case: use `type: :uuid` to handle a confirming given string is a UUID (with or without `-` chars)
@@ -31,11 +35,48 @@ In addition to the [standard ActiveModel validations](https://guides.rubyonrails
31
35
  ```ruby
32
36
  expects :foo, validate: ->(value) { "must be pretty big" unless value > 10 }
33
37
  ```
38
+ * `model: true` (or `model: TheModelClass`) - allows auto-hydrating a record when only given its ID
39
+ * Example:
40
+ ```ruby
41
+ expects :user_id, model: true
42
+ ```
43
+ This line will add expectations that:
44
+ * `user_id` is provided
45
+ * `User.find(user_id)` returns a record
46
+
47
+ And, when used on `expects`, will create two reader methods for you:
48
+ * `user_id` (normal), _and_
49
+ * `user` (for the auto-found record)
50
+
51
+ ::: info NOTES
52
+ * The field name must end in `_id`
53
+ * This was designed for ActiveRecord models, but will work on any class that returns an instance from `find_by(id: <the provided ID>)`
54
+ :::
34
55
 
56
+ ### Details specific to `.exposes`
57
+
58
+ Remember that you'll need [a corresponding `expose` call](/reference/instance#expose) for every variable you declare via `exposes`.
35
59
 
36
60
 
37
61
  ### Details specific to `.expects`
38
62
 
63
+ #### Nested/Subfield expectations
64
+
65
+ `expects` is for defining the inbound interface. Usually it's enough to declare the top-level fields you receive, but sometimes you want to make expectations about the shape of that data, and/or to define easy accessor methods for deeply nested fields. `expects` supports the `on` option for this (all the normal attributes can be applied as well, _except default, preprocess, and sensitive_):
66
+
67
+ ```ruby
68
+ class Foo
69
+ expects :event
70
+ expects :data, type: Hash, on: :event # [!code focus:2]
71
+ expects :some, :random, :fields, on: :data
72
+
73
+ def call
74
+ puts "THe event.data.random field's value is: #{random}"
75
+ end
76
+ end
77
+ ```
78
+
79
+ #### `preprocess`
39
80
  `expects` also supports a `preprocess` option that, if set to a callable, will be executed _before_ applying any validations. This can be useful for type coercion, e.g.:
40
81
 
41
82
  ```ruby
@@ -44,11 +85,6 @@ expects :date, type: Date, preprocess: ->(d) { d.is_a?(Date) ? d : Date.parse(d)
44
85
 
45
86
  will succeed if given _either_ an actual Date object _or_ a string that Date.parse can convert into one. If the preprocess callable raises an exception, that'll be swallowed and the action failed.
46
87
 
47
- ### Details specific to `.exposes`
48
-
49
- Remember that you'll need [a corresponding `expose` call](/reference/instance#expose) for every variable you declare via `exposes`.
50
-
51
-
52
88
  ## `.messages`
53
89
 
54
90
  The `messages` declaration allows you to customize the `error` and `success` messages on the returned result.
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_model"
4
3
  require "active_support/core_ext/enumerable"
5
4
  require "active_support/core_ext/module/delegation"
6
5
 
7
- require "action/core/contract_validator"
6
+ require "action/core/validation/fields"
8
7
  require "action/core/context_facade"
9
8
 
10
9
  module Action
@@ -34,8 +33,18 @@ module Action
34
33
  FieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
35
34
 
36
35
  module ClassMethods
37
- def expects(*fields, allow_blank: false, allow_nil: false, default: nil, preprocess: nil, sensitive: false,
38
- **validations)
36
+ def expects(
37
+ *fields,
38
+ on: nil,
39
+ allow_blank: false,
40
+ allow_nil: false,
41
+ default: nil,
42
+ preprocess: nil,
43
+ sensitive: false,
44
+ **validations
45
+ )
46
+ return _expects_subfields(*fields, on:, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations) if on.present?
47
+
39
48
  fields.each do |field|
40
49
  raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPECTATIONS.include?(field.to_s)
41
50
  end
@@ -49,7 +58,14 @@ module Action
49
58
  end
50
59
  end
51
60
 
52
- def exposes(*fields, allow_blank: false, allow_nil: false, default: nil, sensitive: false, **validations)
61
+ def exposes(
62
+ *fields,
63
+ allow_blank: false,
64
+ allow_nil: false,
65
+ default: nil,
66
+ sensitive: false,
67
+ **validations
68
+ )
53
69
  fields.each do |field|
54
70
  raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPOSURES.include?(field.to_s)
55
71
  end
@@ -77,14 +93,44 @@ module Action
77
93
  ok error success message
78
94
  ].freeze
79
95
 
80
- def _parse_field_configs(*fields, allow_nil: false, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
81
- **validations)
96
+ def _parse_field_configs(
97
+ *fields,
98
+ allow_blank: false,
99
+ allow_nil: false,
100
+ default: nil,
101
+ preprocess: nil,
102
+ sensitive: false,
103
+ **validations
104
+ )
105
+ _parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
106
+ _define_field_reader(field)
107
+ _define_model_reader(field, parsed_validations[:model]) if parsed_validations.key?(:model)
108
+ FieldConfig.new(field:, validations: parsed_validations, default:, preprocess:, sensitive:)
109
+ end
110
+ end
111
+
112
+ def _define_field_reader(field)
82
113
  # Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
83
114
  # (e.g. to allow success message callable to reference exposed fields)
84
- fields.each do |field|
85
- define_method(field) { internal_context.public_send(field) }
115
+ define_method(field) { internal_context.public_send(field) }
116
+ end
117
+
118
+ def _define_model_reader(field, klass)
119
+ name = field.to_s.delete_suffix("_id")
120
+ raise ArgumentError, "Model validation expects to be given a field ending in _id (given: #{field})" unless field.to_s.end_with?("_id")
121
+ raise ArgumentError, "Failed to define model reader - #{name} is already defined" if method_defined?(name)
122
+
123
+ define_method(name) do
124
+ Validators::ModelValidator.instance_for(field:, klass:, id: public_send(field))
86
125
  end
126
+ end
87
127
 
128
+ def _parse_field_validations(
129
+ *fields,
130
+ allow_nil: false,
131
+ allow_blank: false,
132
+ **validations
133
+ )
88
134
  if allow_blank
89
135
  validations.transform_values! do |v|
90
136
  v = { value: v } unless v.is_a?(Hash)
@@ -99,7 +145,7 @@ module Action
99
145
  validations[:presence] = true unless validations.key?(:presence) || Array(validations[:type]).include?(:boolean)
100
146
  end
101
147
 
102
- fields.map { |field| FieldConfig.new(field:, validations:, default:, preprocess:, sensitive:) }
148
+ fields.map { |field| [field, validations] }
103
149
  end
104
150
  end
105
151
 
@@ -164,7 +210,7 @@ module Action
164
210
  context = direction == :inbound ? internal_context : external_context
165
211
  exception_klass = direction == :inbound ? Action::InboundValidationError : Action::OutboundValidationError
166
212
 
167
- ContractValidator.validate!(validations:, context:, exception_klass:)
213
+ Validation::Fields.validate!(validations:, context:, exception_klass:)
168
214
  end
169
215
 
170
216
  def _apply_defaults!(direction)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/validation/subfields"
4
+
5
+ module Action
6
+ module ContractForSubfields
7
+ # TODO: add default, preprocess, sensitive options for subfields?
8
+ # SubfieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
9
+ SubfieldConfig = Data.define(:field, :validations, :on)
10
+
11
+ def self.included(base)
12
+ base.class_eval do
13
+ class_attribute :subfield_configs, default: []
14
+
15
+ extend ClassMethods
16
+ include InstanceMethods
17
+
18
+ before { _validate_subfields_contract! }
19
+ end
20
+ end
21
+
22
+ module ClassMethods
23
+ def _expects_subfields(
24
+ *fields,
25
+ on:,
26
+ readers: true,
27
+ allow_blank: false,
28
+ allow_nil: false,
29
+
30
+ # TODO: add support for these three options for subfields
31
+ default: nil,
32
+ preprocess: nil,
33
+ sensitive: false,
34
+
35
+ **validations
36
+ )
37
+ # TODO: add support for these three options for subfields
38
+ raise ArgumentError, "expects does not support :default key when also given :on" if default.present?
39
+ raise ArgumentError, "expects does not support :preprocess key when also given :on" if preprocess.present?
40
+ raise ArgumentError, "expects does not support :sensitive key when also given :on" if sensitive.present?
41
+
42
+ unless internal_field_configs.map(&:field).include?(on) || subfield_configs.map(&:field).include?(on)
43
+ raise ArgumentError,
44
+ "expects called with `on: #{on}`, but no such method exists (are you sure you've declared `expects :#{on}`?)"
45
+ end
46
+
47
+ raise ArgumentError, "expects does not support expecting fields on nested attributes (i.e. `on` cannot contain periods)" if on.to_s.include?(".")
48
+
49
+ # TODO: consider adding support for default, preprocess, sensitive options for subfields?
50
+ _parse_subfield_configs(*fields, on:, readers:, allow_blank:, allow_nil:, **validations).tap do |configs|
51
+ duplicated = subfield_configs.map(&:field) & configs.map(&:field)
52
+ raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
53
+
54
+ # NOTE: avoid <<, which would update value for parents and children
55
+ self.subfield_configs += configs
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def _parse_subfield_configs(
62
+ *fields,
63
+ on:,
64
+ readers:,
65
+ allow_blank: false,
66
+ allow_nil: false,
67
+ # default: nil,
68
+ # preprocess: nil,
69
+ # sensitive: false,
70
+ **validations
71
+ )
72
+ _parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
73
+ _define_subfield_reader(field, on:, validations: parsed_validations) if readers
74
+ SubfieldConfig.new(field:, validations: parsed_validations, on:)
75
+ end
76
+ end
77
+
78
+ def _define_subfield_reader(field, on:, validations:)
79
+ # Don't create top-level readers for nested fields
80
+ return if field.to_s.include?(".")
81
+
82
+ raise ArgumentError, "expects does not support duplicate sub-keys (i.e. `#{field}` is already defined)" if method_defined?(field)
83
+
84
+ define_memoized_reader_method(field) do
85
+ public_send(on).fetch(field)
86
+ end
87
+
88
+ _define_model_reader(field, validations[:model]) if validations.key?(:model)
89
+ end
90
+
91
+ def define_memoized_reader_method(field, &block)
92
+ define_method(field) do
93
+ ivar = :"@_memoized_reader_#{field}"
94
+ cached_val = instance_variable_get(ivar)
95
+ return cached_val if cached_val.present?
96
+
97
+ value = instance_exec(&block)
98
+ instance_variable_set(ivar, value)
99
+ end
100
+ end
101
+ end
102
+
103
+ module InstanceMethods
104
+ def _validate_subfields_contract!
105
+ return if subfield_configs.blank?
106
+
107
+ subfield_configs.each do |config|
108
+ Validation::Subfields.validate!(
109
+ field: config.field,
110
+ validations: config.validations,
111
+ source: public_send(config.on),
112
+ exception_klass: Action::InboundValidationError,
113
+ )
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Validation
5
+ class Fields
6
+ include ActiveModel::Validations
7
+
8
+ # NOTE: defining classes where needed b/c we explicitly register it'll affect ALL the consuming apps' validators as well
9
+ ModelValidator = Validators::ModelValidator
10
+ TypeValidator = Validators::TypeValidator
11
+ ValidateValidator = Validators::ValidateValidator
12
+
13
+ def initialize(context)
14
+ @context = context
15
+ end
16
+
17
+ def read_attribute_for_validation(attr)
18
+ @context.public_send(attr)
19
+ end
20
+
21
+ def self.validate!(validations:, context:, exception_klass:)
22
+ validator = Class.new(self) do
23
+ def self.name = "Action::Validation::Fields::OneOff"
24
+
25
+ validations.each do |field, field_validations|
26
+ field_validations.each do |key, value|
27
+ validates field, key => value
28
+ end
29
+ end
30
+ end.new(context)
31
+
32
+ return if validator.valid?
33
+
34
+ raise exception_klass, validator.errors
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+
5
+ module Action
6
+ module Validation
7
+ class Subfields
8
+ include ActiveModel::Validations
9
+
10
+ # NOTE: defining classes where needed b/c we explicitly register it'll affect ALL the consuming apps' validators as well
11
+ ModelValidator = Validators::ModelValidator
12
+ TypeValidator = Validators::TypeValidator
13
+ ValidateValidator = Validators::ValidateValidator
14
+
15
+ def initialize(source)
16
+ @source = source
17
+ end
18
+
19
+ def read_attribute_for_validation(attr)
20
+ self.class.extract(attr, @source)
21
+ end
22
+
23
+ def self.extract(attr, source)
24
+ return source.public_send(attr) if source.respond_to?(attr)
25
+ raise "Unclear how to extract #{attr} from #{source.inspect}" unless source.respond_to?(:dig)
26
+
27
+ base = source.respond_to?(:with_indifferent_access) ? source.with_indifferent_access : source
28
+ base.dig(*attr.to_s.split("."))
29
+ end
30
+
31
+ def self.validate!(field:, validations:, source:, exception_klass:)
32
+ validator = Class.new(self) do
33
+ def self.name = "Action::Validation::Subfields::OneOff"
34
+
35
+ validates field, **validations
36
+ end.new(source)
37
+
38
+ return if validator.valid?
39
+
40
+ raise exception_klass, validator.errors
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Action
6
+ module Validators
7
+ class ModelValidator < ActiveModel::EachValidator
8
+ def self.model_for(field:, klass: nil)
9
+ return klass if defined?(ActiveRecord::Base) && klass.is_a?(ActiveRecord::Base)
10
+
11
+ field.to_s.delete_suffix("_id").classify.constantize
12
+ end
13
+
14
+ def self.instance_for(field:, klass:, id:)
15
+ klass = model_for(field:, klass:)
16
+ return unless klass.respond_to?(:find_by)
17
+
18
+ klass.find_by(id:)
19
+ end
20
+
21
+ def validate_each(record, attribute, id)
22
+ klass = self.class.model_for(field: attribute, klass: options[:with])
23
+ instance = self.class.instance_for(field: attribute, klass:, id:)
24
+ return if instance.present?
25
+
26
+ msg = id.blank? ? "not found (given a blank ID)" : "not found for class #{klass.name} and ID #{id}"
27
+ record.errors.add(attribute, msg)
28
+ rescue StandardError => e
29
+ warn("Model validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
30
+
31
+ record.errors.add(attribute, "error raised while trying to find a valid #{klass.name}")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Action
6
+ module Validators
7
+ class TypeValidator < ActiveModel::EachValidator
8
+ def validate_each(record, attribute, value)
9
+ # NOTE: the last one (:value) might be my fault from the make-it-a-hash fallback in #parse_field_configs
10
+ types = options[:in].presence || Array(options[:with]).presence || Array(options[:value]).presence
11
+
12
+ return if value.blank? && !types.include?(:boolean) # Handled with a separate default presence validator
13
+
14
+ msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(", ")}"
15
+ record.errors.add attribute, (options[:message] || msg) unless types.any? do |type|
16
+ if type == :boolean
17
+ [true, false].include?(value)
18
+ elsif type == :uuid
19
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\z/i)
20
+ else
21
+ # NOTE: allow mocks to pass type validation by default (much easier testing ergonomics)
22
+ next true if Action.config.env.test? && value.class.name&.start_with?("RSpec::Mocks::")
23
+
24
+ value.is_a?(type)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Action
6
+ module Validators
7
+ class ValidateValidator < ActiveModel::EachValidator
8
+ def validate_each(record, attribute, value)
9
+ msg = begin
10
+ options[:with].call(value)
11
+ rescue StandardError => e
12
+ Action.config.logger.warn("Custom validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
13
+
14
+ "failed validation: #{e.message}"
15
+ end
16
+
17
+ record.errors.add(attribute, msg) if msg.present?
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/axn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Axn
4
- VERSION = "0.1.0-alpha.2.5"
4
+ VERSION = "0.1.0-alpha.2.5.1.1"
5
5
  end
data/lib/axn.rb CHANGED
@@ -6,11 +6,16 @@ require_relative "axn/version"
6
6
  require "interactor"
7
7
  require "active_support"
8
8
 
9
+ require_relative "action/core/validation/validators/model_validator"
10
+ require_relative "action/core/validation/validators/type_validator"
11
+ require_relative "action/core/validation/validators/validate_validator"
12
+
9
13
  require_relative "action/core/exceptions"
10
14
  require_relative "action/core/logging"
11
15
  require_relative "action/core/configuration"
12
16
  require_relative "action/core/top_level_around_hook"
13
17
  require_relative "action/core/contract"
18
+ require_relative "action/core/contract_for_subfields"
14
19
  require_relative "action/core/swallow_exceptions"
15
20
  require_relative "action/core/hoist_errors"
16
21
 
@@ -37,8 +42,9 @@ module Action
37
42
  # can include those hook executions in any traces set from this hook.
38
43
  include TopLevelAroundHook
39
44
 
40
- include Contract
41
45
  include SwallowExceptions
46
+ include Contract
47
+ include ContractForSubfields
42
48
 
43
49
  include HoistErrors
44
50
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: axn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.2.5
4
+ version: 0.1.0.pre.alpha.2.5.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-06-20 00:00:00.000000000 Z
11
+ date: 2025-06-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -91,13 +91,18 @@ files:
91
91
  - lib/action/core/configuration.rb
92
92
  - lib/action/core/context_facade.rb
93
93
  - lib/action/core/contract.rb
94
- - lib/action/core/contract_validator.rb
94
+ - lib/action/core/contract_for_subfields.rb
95
95
  - lib/action/core/event_handlers.rb
96
96
  - lib/action/core/exceptions.rb
97
97
  - lib/action/core/hoist_errors.rb
98
98
  - lib/action/core/logging.rb
99
99
  - lib/action/core/swallow_exceptions.rb
100
100
  - lib/action/core/top_level_around_hook.rb
101
+ - lib/action/core/validation/fields.rb
102
+ - lib/action/core/validation/subfields.rb
103
+ - lib/action/core/validation/validators/model_validator.rb
104
+ - lib/action/core/validation/validators/type_validator.rb
105
+ - lib/action/core/validation/validators/validate_validator.rb
101
106
  - lib/action/enqueueable.rb
102
107
  - lib/action/enqueueable/enqueue_all_in_background.rb
103
108
  - lib/action/enqueueable/enqueue_all_worker.rb
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Action
4
- class ContractValidator
5
- include ActiveModel::Validations
6
-
7
- def initialize(context)
8
- @context = context
9
- end
10
-
11
- def read_attribute_for_validation(attr)
12
- @context.public_send(attr)
13
- end
14
-
15
- def self.validate!(validations:, context:, exception_klass:)
16
- validator = Class.new(self) do
17
- def self.name = "Action::ContractValidator::OneOff"
18
-
19
- validations.each do |field, field_validations|
20
- field_validations.each do |key, value|
21
- validates field, key => value
22
- end
23
- end
24
- end.new(context)
25
-
26
- return if validator.valid?
27
-
28
- raise exception_klass, validator.errors
29
- end
30
-
31
- # Allow for custom validators to be defined in the context of the action
32
- class ValidateValidator < ActiveModel::EachValidator
33
- def validate_each(record, attribute, value)
34
- msg = begin
35
- options[:with].call(value)
36
- rescue StandardError => e
37
- Action.config.logger.warn("Custom validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
38
-
39
- "failed validation: #{e.message}"
40
- end
41
-
42
- record.errors.add(attribute, msg) if msg.present?
43
- end
44
- end
45
-
46
- class TypeValidator < ActiveModel::EachValidator
47
- def validate_each(record, attribute, value)
48
- # NOTE: the last one (:value) might be my fault from the make-it-a-hash fallback in #parse_field_configs
49
- types = options[:in].presence || Array(options[:with]).presence || Array(options[:value]).presence
50
-
51
- return if value.blank? && !types.include?(:boolean) # Handled with a separate default presence validator
52
-
53
- msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(", ")}"
54
- record.errors.add attribute, (options[:message] || msg) unless types.any? do |type|
55
- if type == :boolean
56
- [true, false].include?(value)
57
- elsif type == :uuid
58
- value.is_a?(String) && value.match?(/\A[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\z/i)
59
- else
60
- next true if Action.config.env.test? && value.class.name.start_with?("RSpec::Mocks::")
61
-
62
- value.is_a?(type)
63
- end
64
- end
65
- end
66
- end
67
- end
68
- end