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 +4 -4
- data/.rubocop.yml +4 -4
- data/CHANGELOG.md +6 -2
- data/docs/reference/class.md +42 -6
- data/lib/action/core/contract.rb +57 -11
- data/lib/action/core/contract_for_subfields.rb +118 -0
- data/lib/action/core/validation/fields.rb +38 -0
- data/lib/action/core/validation/subfields.rb +44 -0
- data/lib/action/core/validation/validators/model_validator.rb +35 -0
- data/lib/action/core/validation/validators/type_validator.rb +30 -0
- data/lib/action/core/validation/validators/validate_validator.rb +21 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +7 -1
- metadata +8 -3
- data/lib/action/core/contract_validator.rb +0 -68
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: eb044bed464d8840289e69cb631ff47ebc44999ac7e6327fc3b69dd62a37a6af
|
4
|
+
data.tar.gz: 0f0db9209a545ac84b88df56aaeaea431e6094385c433775f8d3f6501d84e870
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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:
|
40
|
+
Max: 16
|
41
41
|
|
42
42
|
Metrics/AbcSize:
|
43
43
|
Max: 60
|
44
44
|
|
45
45
|
Metrics/CyclomaticComplexity:
|
46
|
-
Max:
|
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:
|
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
|
-
##
|
4
|
-
*
|
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`
|
data/docs/reference/class.md
CHANGED
@@ -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
|
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.
|
data/lib/action/core/contract.rb
CHANGED
@@ -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/
|
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(
|
38
|
-
|
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(
|
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(
|
81
|
-
|
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
|
-
|
85
|
-
|
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|
|
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
|
-
|
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
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-
|
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/
|
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
|