dry-validation-rust 0.1.0.pre5
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 +7 -0
- data/CHANGELOG.md +74 -0
- data/LICENSE +21 -0
- data/NOTICE.md +28 -0
- data/README.md +459 -0
- data/docs/ARCHITECTURE.md +256 -0
- data/docs/COMPATIBILITY.md +198 -0
- data/docs/FEASIBILITY.md +207 -0
- data/docs/SUPPORT_MATRIX.md +66 -0
- data/docs/VERIFICATION.md +128 -0
- data/dry-validation-rust.gemspec +58 -0
- data/ext/dry_validation_rust/Cargo.lock +809 -0
- data/ext/dry_validation_rust/Cargo.toml +44 -0
- data/ext/dry_validation_rust/benches/coercion.rs +77 -0
- data/ext/dry_validation_rust/benches/full_schema.rs +189 -0
- data/ext/dry_validation_rust/benches/plan_compile.rs +37 -0
- data/ext/dry_validation_rust/benches/predicates.rs +105 -0
- data/ext/dry_validation_rust/extconf.rb +29 -0
- data/ext/dry_validation_rust/src/coercion.rs +515 -0
- data/ext/dry_validation_rust/src/engine.rs +416 -0
- data/ext/dry_validation_rust/src/error.rs +82 -0
- data/ext/dry_validation_rust/src/extract_primitive.rs +23 -0
- data/ext/dry_validation_rust/src/generated_predicates.rs +33 -0
- data/ext/dry_validation_rust/src/lib.rs +228 -0
- data/ext/dry_validation_rust/src/plan.rs +611 -0
- data/ext/dry_validation_rust/src/predicates.rs +449 -0
- data/ext/dry_validation_rust/src/ruby_bridge.rs +78 -0
- data/lib/dry/schema.rb +6 -0
- data/lib/dry/validation/rust/block_keyword_parameters.rb +20 -0
- data/lib/dry/validation/rust/config.rb +74 -0
- data/lib/dry/validation/rust/contract/result.rb +180 -0
- data/lib/dry/validation/rust/contract/values.rb +73 -0
- data/lib/dry/validation/rust/contract.rb +400 -0
- data/lib/dry/validation/rust/errors.rb +14 -0
- data/lib/dry/validation/rust/evaluator.rb +295 -0
- data/lib/dry/validation/rust/failures.rb +57 -0
- data/lib/dry/validation/rust/generated_predicates.rb +14 -0
- data/lib/dry/validation/rust/macros.rb +45 -0
- data/lib/dry/validation/rust/message.rb +41 -0
- data/lib/dry/validation/rust/message_backend.rb +115 -0
- data/lib/dry/validation/rust/message_set.rb +159 -0
- data/lib/dry/validation/rust/native.rb +25 -0
- data/lib/dry/validation/rust/path.rb +65 -0
- data/lib/dry/validation/rust/path_trie.rb +57 -0
- data/lib/dry/validation/rust/result.rb +3 -0
- data/lib/dry/validation/rust/rule.rb +62 -0
- data/lib/dry/validation/rust/schema/dsl.rb +76 -0
- data/lib/dry/validation/rust/schema/field_builder.rb +156 -0
- data/lib/dry/validation/rust/schema/field_definition.rb +99 -0
- data/lib/dry/validation/rust/schema/predicate_block.rb +56 -0
- data/lib/dry/validation/rust/schema/processor_hooks.rb +46 -0
- data/lib/dry/validation/rust/schema/result.rb +67 -0
- data/lib/dry/validation/rust/schema/ruby_type_processor.rb +44 -0
- data/lib/dry/validation/rust/schema.rb +323 -0
- data/lib/dry/validation/rust/values.rb +3 -0
- data/lib/dry/validation/rust/version.rb +10 -0
- data/lib/dry/validation/rust.rb +55 -0
- data/lib/dry/validation.rb +66 -0
- data/lib/dry-schema.rb +3 -0
- data/lib/dry-validation.rb +3 -0
- data/lib/dry_validation_rust.rb +3 -0
- data/predicates.yml +67 -0
- data/rust-toolchain.toml +9 -0
- metadata +260 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Contract
|
|
7
|
+
# The outcome of calling a {Contract}.
|
|
8
|
+
#
|
|
9
|
+
# A result contains coerced output, schema and rule failures, and the
|
|
10
|
+
# context supplied to the call. Use {#success?} or {#failure?} to check
|
|
11
|
+
# the outcome, {#to_h} to read the output, and {#errors} to inspect
|
|
12
|
+
# failures.
|
|
13
|
+
#
|
|
14
|
+
# @example Validating input and matching the output
|
|
15
|
+
# result = UserContract.new.call(name: "Ada")
|
|
16
|
+
#
|
|
17
|
+
# case result
|
|
18
|
+
# in { name: String => name }
|
|
19
|
+
# name # => "Ada"
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @see Contract#call
|
|
23
|
+
class Result
|
|
24
|
+
# @return [Schema::Result] structural validation outcome.
|
|
25
|
+
attr_reader :schema_result
|
|
26
|
+
# @return [Hash] context supplied to the contract call.
|
|
27
|
+
attr_reader :context
|
|
28
|
+
|
|
29
|
+
# Creates a result from a schema result and call context.
|
|
30
|
+
#
|
|
31
|
+
# @param schema_result [Schema::Result] structural validation outcome
|
|
32
|
+
# @param context [Hash] context supplied to the contract call
|
|
33
|
+
# @return [Result]
|
|
34
|
+
def initialize(schema_result, context = {})
|
|
35
|
+
@schema_result = schema_result
|
|
36
|
+
@context = context
|
|
37
|
+
@rule_messages = []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns validated output wrapped in a Values object.
|
|
41
|
+
#
|
|
42
|
+
# @return [Values] coerced output with key and path access
|
|
43
|
+
def values
|
|
44
|
+
@values ||= Values.new(schema_result.to_h)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns schema and rule errors, optionally with display options.
|
|
48
|
+
#
|
|
49
|
+
# Call {MessageSet#messages} on the returned set for its immutable
|
|
50
|
+
# message-object view, or {MessageSet#to_h} for nested error hashes.
|
|
51
|
+
#
|
|
52
|
+
# @param options [Hash] message rendering options; pass `full: true`
|
|
53
|
+
# to include full message text
|
|
54
|
+
# @return [MessageSet] combined schema and rule errors
|
|
55
|
+
def errors(options = {})
|
|
56
|
+
set = MessageSet.new([*schema_result.messages, *@rule_messages], options)
|
|
57
|
+
options.empty? ? set : set.with(options)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Adds a rule message before finalization and returns this result.
|
|
61
|
+
#
|
|
62
|
+
# @param message [Message] rule failure to add
|
|
63
|
+
# @return [Result] this result
|
|
64
|
+
def add_error(message)
|
|
65
|
+
@rule_messages << message
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns true when no schema or rule messages exist.
|
|
70
|
+
#
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def success?
|
|
73
|
+
errors.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns true when at least one schema or rule message exists.
|
|
77
|
+
#
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def failure?
|
|
80
|
+
!success?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns whether a message exists at or below a path.
|
|
84
|
+
#
|
|
85
|
+
# @param key [Symbol, String, Array, Hash] key or supported path specification
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def error?(key)
|
|
88
|
+
path = Path.parse(key)
|
|
89
|
+
errors.any? { |message| Path.prefix?(message.path, path) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns whether a schema message exists at or below a path.
|
|
93
|
+
#
|
|
94
|
+
# @param key [Symbol, String, Array, Hash] key or supported path specification
|
|
95
|
+
# @return [Boolean]
|
|
96
|
+
def schema_error?(key)
|
|
97
|
+
schema_result.error?(key)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns whether a rule message exists at or below a path.
|
|
101
|
+
#
|
|
102
|
+
# @param key [Symbol, String, Array, Hash] key or supported path specification
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def rule_error?(key)
|
|
105
|
+
path = Path.parse(key)
|
|
106
|
+
@rule_messages.any? { |message| Path.prefix?(message.path, path) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns whether a base-level rule message exists.
|
|
110
|
+
#
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def base_rule_error?
|
|
113
|
+
@rule_messages.any?(&:base?)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Reads a validated value by key or path.
|
|
117
|
+
#
|
|
118
|
+
# @param key [Symbol, String, Array] key or supported path specification
|
|
119
|
+
# @return [Object, nil] coerced value, if present
|
|
120
|
+
def [](key)
|
|
121
|
+
values[key]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns whether validated output contains a key or path.
|
|
125
|
+
#
|
|
126
|
+
# @param key [Symbol, String, Array] key or supported path specification
|
|
127
|
+
# @return [Boolean]
|
|
128
|
+
def key?(key)
|
|
129
|
+
values.key?(key)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Returns validated output as a Hash.
|
|
133
|
+
#
|
|
134
|
+
# @return [Hash] coerced output
|
|
135
|
+
def to_h
|
|
136
|
+
values.to_h
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Returns a diagnostic representation of output, errors, and context.
|
|
140
|
+
#
|
|
141
|
+
# @return [String]
|
|
142
|
+
def inspect
|
|
143
|
+
if context.empty?
|
|
144
|
+
"#<#{self.class}#{to_h.inspect} errors=#{errors.to_h.inspect}>"
|
|
145
|
+
else
|
|
146
|
+
"#<#{self.class}#{to_h.inspect} errors=#{errors.to_h.inspect} context=#{context.inspect}>"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Deconstructs coerced output for Hash pattern matching.
|
|
151
|
+
#
|
|
152
|
+
# This lets a result match as though it were its output hash, such as
|
|
153
|
+
# `in { name: String => name }`. The call context is not included in
|
|
154
|
+
# this matching form; use {#deconstruct} for positional matching.
|
|
155
|
+
#
|
|
156
|
+
# @param keys [Array<Symbol>, nil] requested keys, or +nil+ for all keys
|
|
157
|
+
# @return [Hash] output entries available to the pattern
|
|
158
|
+
def deconstruct_keys(keys)
|
|
159
|
+
values.deconstruct_keys(keys)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Supports tuple pattern matching as values and context.
|
|
163
|
+
#
|
|
164
|
+
# @return [Array<(Values, Hash)>] coerced output and call context
|
|
165
|
+
def deconstruct
|
|
166
|
+
[values, context]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Prevents further rule-message mutation and returns this result.
|
|
170
|
+
#
|
|
171
|
+
# @return [Result] this finalized result
|
|
172
|
+
def finalize!
|
|
173
|
+
@rule_messages.freeze
|
|
174
|
+
self
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Contract
|
|
7
|
+
class Values
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
# @return [Hash] validated output data.
|
|
11
|
+
attr_reader :data
|
|
12
|
+
|
|
13
|
+
# Wraps validated output data for rule and result access.
|
|
14
|
+
def initialize(data)
|
|
15
|
+
@data = data
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Reads a value by key, path, or supported multi-path specification.
|
|
19
|
+
def [](*args)
|
|
20
|
+
return data.dig(*args) if args.length > 1
|
|
21
|
+
|
|
22
|
+
spec = args.fetch(0)
|
|
23
|
+
if spec.is_a?(Hash) && spec.values.first.is_a?(Array)
|
|
24
|
+
head = spec.keys.first
|
|
25
|
+
return spec.values.first.map { |tail| Path.fetch(data, [head, *Path.parse(tail)], nil) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
value = Path.fetch(data, spec)
|
|
29
|
+
value.equal?(Path::Undefined) ? nil : value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns whether data contains a key or path.
|
|
33
|
+
def key?(key)
|
|
34
|
+
Path.key?(data, key)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Iterates through the underlying output Hash.
|
|
38
|
+
def each(&)
|
|
39
|
+
data.each(&)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fetches a value using Hash#fetch semantics.
|
|
43
|
+
def fetch(*, &)
|
|
44
|
+
data.fetch(*, &)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns the underlying output Hash.
|
|
48
|
+
def to_h
|
|
49
|
+
data
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Supports Hash pattern matching against output data.
|
|
53
|
+
def deconstruct_keys(keys)
|
|
54
|
+
keys ? data.slice(*keys) : data
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reports public Hash methods delegated to underlying data.
|
|
58
|
+
def respond_to_missing?(name, include_private = false)
|
|
59
|
+
data.respond_to?(name, include_private) || super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def method_missing(name, ...)
|
|
65
|
+
return data.public_send(name, ...) if data.respond_to?(name)
|
|
66
|
+
|
|
67
|
+
super
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
# A validation contract that combines a compiled schema with ordered rules.
|
|
7
|
+
#
|
|
8
|
+
# @example Defining a contract
|
|
9
|
+
# class UserContract < Dry::Validation::Rust::Contract
|
|
10
|
+
# params do
|
|
11
|
+
# required(:email).filled(:string)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# rule(:email) do
|
|
15
|
+
# key.failure("is invalid") unless value.include?("@")
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
class Contract
|
|
19
|
+
Undefined = Object.new.freeze
|
|
20
|
+
# @api private
|
|
21
|
+
OptionDefinition = Data.define(:name, :default, :optional) do
|
|
22
|
+
def initialize(name:, default: Contract::Undefined, optional: false)
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Copies schema configuration and macros when a contract is inherited.
|
|
29
|
+
#
|
|
30
|
+
# @param child [Class] subclass inheriting this contract
|
|
31
|
+
# @return [void]
|
|
32
|
+
def inherited(child)
|
|
33
|
+
super
|
|
34
|
+
child.instance_variable_set(:@config, config.dup)
|
|
35
|
+
child.instance_variable_set(:@macro_registry, MacroRegistry.new(macro_registry))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns this contract class's configuration.
|
|
39
|
+
#
|
|
40
|
+
# @return [Config] mutable schema configuration for this class
|
|
41
|
+
def config
|
|
42
|
+
@config ||= Config.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Defines or returns a Params-mode schema for this contract.
|
|
46
|
+
#
|
|
47
|
+
# @example Defining a params schema
|
|
48
|
+
# params { required(:age).value(:integer) }
|
|
49
|
+
#
|
|
50
|
+
# @param external_schemas [Array<Schema>] schemas to import
|
|
51
|
+
# @yield schema definition DSL
|
|
52
|
+
# @return [Schema] the compiled schema
|
|
53
|
+
# @raise [DuplicateSchemaError] if this class already has a schema
|
|
54
|
+
def params(*external_schemas, &)
|
|
55
|
+
define_schema(:params, external_schemas, &)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Defines or returns a JSON-mode schema for this contract.
|
|
59
|
+
#
|
|
60
|
+
# @example Defining a JSON schema
|
|
61
|
+
# json { required(:name).filled(:string) }
|
|
62
|
+
#
|
|
63
|
+
# @param external_schemas [Array<Schema>] schemas to import
|
|
64
|
+
# @yield schema definition DSL
|
|
65
|
+
# @return [Schema] the compiled schema
|
|
66
|
+
# @raise [DuplicateSchemaError] if this class already has a schema
|
|
67
|
+
def json(*external_schemas, &)
|
|
68
|
+
define_schema(:json, external_schemas, &)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Defines or returns a schema-mode schema for this contract.
|
|
72
|
+
#
|
|
73
|
+
# @example Defining a schema-mode schema
|
|
74
|
+
# schema { required(:name).filled(:string) }
|
|
75
|
+
#
|
|
76
|
+
# @param external_schemas [Array<Schema>] schemas to import
|
|
77
|
+
# @yield schema definition DSL
|
|
78
|
+
# @return [Schema] the compiled schema
|
|
79
|
+
# @raise [DuplicateSchemaError] if this class already has a schema
|
|
80
|
+
def schema(*external_schemas, &)
|
|
81
|
+
define_schema(:schema, external_schemas, &)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Registers a validation rule for one or more schema paths.
|
|
85
|
+
#
|
|
86
|
+
# @example Validating a nested field
|
|
87
|
+
# rule("profile.email") { key.failure("is invalid") unless value.include?("@") }
|
|
88
|
+
#
|
|
89
|
+
# @param specs [Array<Symbol, String, Array, Hash>] schema path specifications
|
|
90
|
+
# @yield rule evaluated after successful schema validation
|
|
91
|
+
# @return [Rule] the registered rule
|
|
92
|
+
# @raise [SchemaMissingError] if no schema has been declared
|
|
93
|
+
# @raise [InvalidKeysError] if a path is not declared by the schema
|
|
94
|
+
def rule(*specs, &block)
|
|
95
|
+
paths = specs.flat_map { |spec| Path.expand(spec) }
|
|
96
|
+
ensure_valid_paths(paths) unless paths.empty?
|
|
97
|
+
Rule.new(paths: paths, default_path: default_rule_path(specs, paths), block: block).tap do |new_rule|
|
|
98
|
+
own_rules << new_rule
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns inherited and locally declared rules in execution order.
|
|
103
|
+
#
|
|
104
|
+
# @return [Array<Rule>] rules evaluated by instances of this contract
|
|
105
|
+
def rules
|
|
106
|
+
inherited_rules = superclass.respond_to?(:rules) ? superclass.rules : []
|
|
107
|
+
[*inherited_rules, *own_rules]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns rules declared directly on this contract class.
|
|
111
|
+
#
|
|
112
|
+
# @return [Array<Rule>] rules declared without inheritance
|
|
113
|
+
def own_rules
|
|
114
|
+
@own_rules ||= []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Declares an injected contract option and its default behavior.
|
|
118
|
+
#
|
|
119
|
+
# @example Requiring an injected dependency
|
|
120
|
+
# option :repository
|
|
121
|
+
# rule(:email) { key.failure("is taken") if repository.taken?(value) }
|
|
122
|
+
#
|
|
123
|
+
# @param name [Symbol, String] option reader name
|
|
124
|
+
# @param default [Object, Proc] value, or callable default, used when omitted
|
|
125
|
+
# @param optional [Boolean] whether an omitted option is set to +nil+
|
|
126
|
+
# @return [Class] this contract class
|
|
127
|
+
def option(name, default: Undefined, optional: false, **_options)
|
|
128
|
+
(@option_definitions ||= {})[name.to_sym] = OptionDefinition.new(
|
|
129
|
+
name: name.to_sym,
|
|
130
|
+
default: default,
|
|
131
|
+
optional: optional
|
|
132
|
+
)
|
|
133
|
+
attr_reader name
|
|
134
|
+
|
|
135
|
+
self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returns option definitions inherited by this contract class.
|
|
139
|
+
#
|
|
140
|
+
# @return [Hash{Symbol => OptionDefinition}] inherited and local definitions
|
|
141
|
+
def option_definitions
|
|
142
|
+
inherited = superclass.respond_to?(:option_definitions) ? superclass.option_definitions : {}
|
|
143
|
+
inherited.merge(@option_definitions ||= {})
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Registers a macro available to rules on this contract class.
|
|
147
|
+
#
|
|
148
|
+
# @example Registering a macro
|
|
149
|
+
# register_macro(:check_name) { key.failure("is invalid") unless value.match?(/\A[A-Z]/) }
|
|
150
|
+
#
|
|
151
|
+
# @param name [Symbol, String] macro name
|
|
152
|
+
# Positional arguments are forwarded to the macro registry.
|
|
153
|
+
# @yield macro implementation
|
|
154
|
+
# @return [Class] this contract class
|
|
155
|
+
def register_macro(name, *, &)
|
|
156
|
+
macro_registry.register(name, *, &)
|
|
157
|
+
self
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Returns this contract class's macro registry.
|
|
161
|
+
#
|
|
162
|
+
# @api private
|
|
163
|
+
#
|
|
164
|
+
# @return [MacroRegistry] registry used to resolve rule macros
|
|
165
|
+
def macro_registry
|
|
166
|
+
@macro_registry ||= MacroRegistry.new(Rust.global_macros)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Enables supported predicates to be resolved as rule macros.
|
|
170
|
+
#
|
|
171
|
+
# @return [Class] this contract class
|
|
172
|
+
def import_predicates_as_macros
|
|
173
|
+
@predicates_as_macros = true
|
|
174
|
+
self
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Builds an anonymous contract instance, optionally configured by a block.
|
|
178
|
+
#
|
|
179
|
+
# @example Building a one-off contract
|
|
180
|
+
# contract = Contract.build { params { required(:name).filled(:string) } }
|
|
181
|
+
# contract.call(name: "Ada").success? # => true
|
|
182
|
+
#
|
|
183
|
+
# @param options [Hash{Symbol => Object}] options passed to the new instance
|
|
184
|
+
# @yield anonymous contract class definition
|
|
185
|
+
# @return [Contract] a configured anonymous contract instance
|
|
186
|
+
def build(options = {}, &)
|
|
187
|
+
Class.new(self, &).new(**options)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Returns the compiled schema declared by this class or an ancestor.
|
|
191
|
+
#
|
|
192
|
+
# @return [Schema, nil] the compiled schema, if one has been declared
|
|
193
|
+
def schema_definition
|
|
194
|
+
return @schema_definition if instance_variable_defined?(:@schema_definition)
|
|
195
|
+
|
|
196
|
+
superclass.schema_definition if superclass.respond_to?(:schema_definition)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def define_schema(mode, external_schemas, &block)
|
|
202
|
+
return schema_definition if external_schemas.empty? && block.nil?
|
|
203
|
+
if instance_variable_defined?(:@schema_definition)
|
|
204
|
+
raise DuplicateSchemaError,
|
|
205
|
+
'Schema has already been defined'
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
builder = Schema::DSL.new(mode: mode)
|
|
209
|
+
parent = superclass.schema_definition if superclass.respond_to?(:schema_definition)
|
|
210
|
+
builder.import(parent) if parent
|
|
211
|
+
external_schemas.each { |external| builder.import(external) }
|
|
212
|
+
builder.instance_eval(&block) if block
|
|
213
|
+
@schema_definition = builder.compile(validate_keys: config.validate_keys, messages: config.messages.dup)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def ensure_valid_paths(paths)
|
|
217
|
+
schema = schema_definition
|
|
218
|
+
raise SchemaMissingError, "#{name || self} must define a schema before rules" unless schema
|
|
219
|
+
|
|
220
|
+
valid = schema.key_paths
|
|
221
|
+
invalid = paths.reject do |path|
|
|
222
|
+
valid.any? do |candidate|
|
|
223
|
+
comparable = candidate.reject { |part| part == :__index__ }
|
|
224
|
+
comparable == path || Path.prefix?(comparable, path) || Path.prefix?(path, comparable)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
return if invalid.empty?
|
|
228
|
+
|
|
229
|
+
raise InvalidKeysError,
|
|
230
|
+
"#{name || self}.rule specifies keys that are not defined by the schema: #{invalid.inspect}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def default_rule_path(specs, paths)
|
|
234
|
+
spec = specs.first
|
|
235
|
+
return paths.first || [] unless specs.length == 1 && spec.is_a?(Hash) && spec.length == 1
|
|
236
|
+
|
|
237
|
+
key, value = spec.first
|
|
238
|
+
value.is_a?(Array) ? [key, value] : paths.first || []
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Returns context merged into every call to this contract.
|
|
243
|
+
#
|
|
244
|
+
# @return [Hash] context merged into every call to this contract
|
|
245
|
+
attr_reader :default_context
|
|
246
|
+
|
|
247
|
+
# Creates a contract with optional default context and declared options.
|
|
248
|
+
#
|
|
249
|
+
# @param default_context [Hash] context merged with call-specific context
|
|
250
|
+
# @param options [Hash{Symbol => Object}] values for declared options
|
|
251
|
+
# @return [Contract] a configured contract instance
|
|
252
|
+
# @raise [ArgumentError] if an option is missing or unknown
|
|
253
|
+
def initialize(default_context: {}, **options)
|
|
254
|
+
@default_context = default_context
|
|
255
|
+
initialize_options(options)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Validates input and returns a finalized result, including rule failures.
|
|
259
|
+
#
|
|
260
|
+
# @example Validating input
|
|
261
|
+
# result = contract.call(email: "ada@example.test")
|
|
262
|
+
# result.success? # => true
|
|
263
|
+
#
|
|
264
|
+
# @param input [Hash] input accepted by the declared schema
|
|
265
|
+
# @param context [Hash] context available to rule evaluators for this call
|
|
266
|
+
# @return [Result] finalized schema and rule validation result
|
|
267
|
+
# @raise [SchemaMissingError] if this contract has no schema
|
|
268
|
+
def call(input, context = {})
|
|
269
|
+
schema = self.class.schema_definition
|
|
270
|
+
raise SchemaMissingError, "#{self.class} must define a schema" unless schema
|
|
271
|
+
|
|
272
|
+
schema_result = schema.call(input)
|
|
273
|
+
shared_context = default_context.merge(context)
|
|
274
|
+
result = Result.new(schema_result, shared_context)
|
|
275
|
+
schema_error_paths = schema_result.error_prefixes
|
|
276
|
+
|
|
277
|
+
self.class.rules.each do |rule|
|
|
278
|
+
if rule.each?
|
|
279
|
+
execute_each(rule, result, shared_context)
|
|
280
|
+
else
|
|
281
|
+
next if rule.paths.any? { |path| dependency_error?(schema_error_paths, path) }
|
|
282
|
+
|
|
283
|
+
execute_rule(rule, result, shared_context)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
result.finalize!
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Validates input with bracket syntax; equivalent to {#call}.
|
|
291
|
+
#
|
|
292
|
+
# @example Calling with bracket syntax
|
|
293
|
+
# contract[email: "ada@example.test"]
|
|
294
|
+
#
|
|
295
|
+
# @param input [Hash] input accepted by the declared schema
|
|
296
|
+
# @param context [Hash] context available to rule evaluators for this call
|
|
297
|
+
# @return [Result] finalized schema and rule validation result
|
|
298
|
+
# @raise [SchemaMissingError] if this contract has no schema
|
|
299
|
+
def [](input, context = {})
|
|
300
|
+
call(input, context)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Returns whether a macro can be resolved by this contract.
|
|
304
|
+
#
|
|
305
|
+
# @api private
|
|
306
|
+
#
|
|
307
|
+
# @param name [Symbol, String] macro name
|
|
308
|
+
# @return [Boolean] whether the macro is registered
|
|
309
|
+
def macro_registered?(name)
|
|
310
|
+
self.class.macro_registry.key?(name)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Resolves a registered macro by name.
|
|
314
|
+
#
|
|
315
|
+
# @api private
|
|
316
|
+
#
|
|
317
|
+
# @param name [Symbol, String] macro name
|
|
318
|
+
# @return [Macro] registered macro implementation
|
|
319
|
+
# @raise [KeyError] if no macro is registered with +name+
|
|
320
|
+
def resolve_macro(name)
|
|
321
|
+
self.class.macro_registry.fetch(name)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Returns a diagnostic representation of the compiled contract.
|
|
325
|
+
#
|
|
326
|
+
# @return [String] contract class, schema, and rules
|
|
327
|
+
def inspect
|
|
328
|
+
"#<#{self.class} schema=#{self.class.schema_definition.inspect} rules=#{self.class.rules.inspect}>"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private
|
|
332
|
+
|
|
333
|
+
# @api private
|
|
334
|
+
def initialize_options(provided)
|
|
335
|
+
definitions = self.class.option_definitions
|
|
336
|
+
unknown = provided.keys - definitions.keys
|
|
337
|
+
if unknown.any?
|
|
338
|
+
raise ArgumentError,
|
|
339
|
+
"unknown keyword#{'s' if unknown.length > 1}: #{unknown.map(&:inspect).join(', ')}"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
definitions.each_value do |definition|
|
|
343
|
+
value = if provided.key?(definition.name)
|
|
344
|
+
provided[definition.name]
|
|
345
|
+
elsif !definition.default.equal?(Undefined)
|
|
346
|
+
definition.default.respond_to?(:call) ? definition.default.call : definition.default
|
|
347
|
+
elsif definition.optional
|
|
348
|
+
nil
|
|
349
|
+
else
|
|
350
|
+
raise ArgumentError, "missing keyword: :#{definition.name}"
|
|
351
|
+
end
|
|
352
|
+
instance_variable_set("@#{definition.name}", value)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @api private
|
|
357
|
+
def dependency_error?(schema_error_paths, path)
|
|
358
|
+
schema_error_paths.prefix?(path)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @api private
|
|
362
|
+
def execute_rule(rule, result, context)
|
|
363
|
+
run_evaluator(rule, result, context, paths: rule.paths, default_path: rule.default_path)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# @api private
|
|
367
|
+
def execute_each(rule, result, context)
|
|
368
|
+
root = rule.paths.first
|
|
369
|
+
collection = Path.fetch(result.to_h, root)
|
|
370
|
+
return if collection.equal?(Path::Undefined) || collection.nil?
|
|
371
|
+
return unless collection.respond_to?(:each_with_index)
|
|
372
|
+
|
|
373
|
+
collection.each_with_index do |_item, index|
|
|
374
|
+
item_path = root.dup
|
|
375
|
+
item_path << index
|
|
376
|
+
next if result.schema_error?(item_path)
|
|
377
|
+
|
|
378
|
+
run_evaluator(rule, result, context, paths: [item_path], default_path: item_path, index: index)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# @api private
|
|
383
|
+
def run_evaluator(rule, result, context, paths:, default_path:, index: nil)
|
|
384
|
+
evaluator = Evaluator.new(
|
|
385
|
+
contract: self,
|
|
386
|
+
result: result,
|
|
387
|
+
paths: paths,
|
|
388
|
+
default_path: default_path,
|
|
389
|
+
context: context,
|
|
390
|
+
index: index
|
|
391
|
+
)
|
|
392
|
+
evaluator.send(:execute, rule.block, rule.macro_calls,
|
|
393
|
+
keyword_params: rule.keyword_params).send(:failures).each do |failure|
|
|
394
|
+
result.add_error(failure)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
module Validation
|
|
5
|
+
module Rust
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class SchemaMissingError < Error; end
|
|
8
|
+
class DuplicateSchemaError < Error; end
|
|
9
|
+
class InvalidKeysError < Error; end
|
|
10
|
+
class UnsupportedFeatureError < Error; end
|
|
11
|
+
class NativeExtensionError < Error; end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|