action_spec 1.5.0 → 1.6.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.
- checksums.yaml +4 -4
- data/README.md +111 -23
- data/lib/action_spec/configuration.rb +3 -1
- data/lib/action_spec/schema/active_record.rb +159 -24
- data/lib/action_spec/schema/array_of.rb +8 -4
- data/lib/action_spec/schema/base.rb +20 -14
- data/lib/action_spec/schema/field.rb +27 -3
- data/lib/action_spec/schema/object_of.rb +9 -5
- data/lib/action_spec/schema/resolver.rb +17 -10
- data/lib/action_spec/schema/scalar.rb +11 -3
- data/lib/action_spec/schema.rb +32 -6
- data/lib/action_spec/validation_result.rb +3 -1
- data/lib/action_spec/validator/runner.rb +1 -1
- data/lib/action_spec/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4483d08f3dbf8917159d539ee9b064366551409ae646714def0b89595484428
|
|
4
|
+
data.tar.gz: 065af0d1f835124179afef0a756515b6184598cd35ef217a96615f18b2098cbe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 71ae3fd218312cb0e788f7a5affad8e31da416546e319852509d260490fe261009be24fc51cedfc5948c8c7e9cee04285b50019e613316f3a96605d7cee98b1f
|
|
7
|
+
data.tar.gz: 7aaee031ed042a3631c952789ccf4ca37db7f7a7f53fcb6a45f1a0e8c4badad005cd4e8774fad58d467c8b2f49d543f4d499407b98cad4dde8fe4e6dbe7cc197
|
data/README.md
CHANGED
|
@@ -201,6 +201,16 @@ cookie! :remember_token, String
|
|
|
201
201
|
|
|
202
202
|
Bang methods mark the field as required. For example, `query! :page, Integer` means the request must include `page`, and the value must not be `nil`. Blank values are still allowed unless you set `blank: false`.
|
|
203
203
|
|
|
204
|
+
You can also change that default globally:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
ActionSpec.configure do |config|
|
|
208
|
+
config.required_allow_blank = false
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
With `required_allow_blank = false`, required fields reject blank strings unless that field explicitly sets `blank:` or `allow_blank:`.
|
|
213
|
+
|
|
204
214
|
If you prefer not to use bang methods, you can also write `required: true`:
|
|
205
215
|
|
|
206
216
|
```ruby
|
|
@@ -371,7 +381,9 @@ Meaning of `!`:
|
|
|
371
381
|
|
|
372
382
|
You can also use `required: true` instead of bang syntax for parameters, nested fields, and the root request body.
|
|
373
383
|
|
|
374
|
-
`required` in ActionSpec means "present and not `nil`".
|
|
384
|
+
`required` in ActionSpec means "present and not `nil`". By default it does not reject blank strings. You can keep that default, change it globally with `config.required_allow_blank`, or override it per field with `blank:` / `allow_blank:`.
|
|
385
|
+
|
|
386
|
+
When blank values are allowed, type coercion does not fail just because the input is an empty string. If the field can still carry a meaningful blank value, such as `String`, the original blank string stays in `px`. Otherwise, ActionSpec stores `nil` for that field. For example, `""` stays `""` for `String`, but becomes `nil` for `Date`.
|
|
375
387
|
|
|
376
388
|
#### Field Types
|
|
377
389
|
|
|
@@ -425,21 +437,58 @@ query :today, Date, default: -> { Time.current.to_date }
|
|
|
425
437
|
query :status, String, enum: %w[draft published]
|
|
426
438
|
query :score, Integer, range: { ge: 1, le: 5 }
|
|
427
439
|
query :slug, String, pattern: /\A[a-z\-]+\z/
|
|
428
|
-
query :title, String, blank: false
|
|
440
|
+
query :title, String, blank: false
|
|
429
441
|
|
|
430
442
|
query :nickname, String, transform: :downcase
|
|
431
443
|
query :page, Integer, transform: -> { it + 1 }, px: :page_number
|
|
432
|
-
query :
|
|
433
|
-
query :
|
|
444
|
+
query :end_at, Integer, validate: -> { current_user && it >= px[:start_at] }
|
|
445
|
+
query :birthday, Date, error: "birthday error"
|
|
434
446
|
```
|
|
435
447
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
-
|
|
439
|
-
- `
|
|
440
|
-
-
|
|
441
|
-
-
|
|
442
|
-
-
|
|
448
|
+
- `required`
|
|
449
|
+
- Marks the field as required.
|
|
450
|
+
- Can replace bang syntax when you do not use `name!:`.
|
|
451
|
+
- `default`
|
|
452
|
+
- Default value used when the field is missing.
|
|
453
|
+
- Can be a literal or `-> { }`.
|
|
454
|
+
- `enum`
|
|
455
|
+
- Restricts the field to values from a fixed set.
|
|
456
|
+
- `range`
|
|
457
|
+
- Numeric range constraints.
|
|
458
|
+
- Available: `ge` / `gt` / `le` / `lt`
|
|
459
|
+
- `pattern`
|
|
460
|
+
- Regex constraint.
|
|
461
|
+
- `length`
|
|
462
|
+
- Length constraints.
|
|
463
|
+
- Available: `minimum` / `maximum` / `is`
|
|
464
|
+
- `blank` / `allow_blank`
|
|
465
|
+
- Controls whether blank values are allowed.
|
|
466
|
+
- For fields such as `Date`, if blank is allowed, no type coercion is applied and the value becomes `nil`.
|
|
467
|
+
|
|
468
|
+
- `desc`
|
|
469
|
+
- Used only for OpenAPI description generation.
|
|
470
|
+
- `example`
|
|
471
|
+
- Used only for generating a single OpenAPI example.
|
|
472
|
+
- `examples`
|
|
473
|
+
- Used only for generating multiple OpenAPI examples.
|
|
474
|
+
|
|
475
|
+
- `transform`
|
|
476
|
+
- Applies one more custom transformation to the **already-coerced value**.
|
|
477
|
+
- Accepts a `Symbol` or a `Proc`.
|
|
478
|
+
- `px` / `px_key`
|
|
479
|
+
- Customize the key name used when the parameter is written into `px`.
|
|
480
|
+
- `validate`
|
|
481
|
+
- Accepts a `Proc`.
|
|
482
|
+
- Runs **after all parameters have finished resolving, coercion, transform, and writing into `px`**.
|
|
483
|
+
- Runs in the current controller context, so it can read `px` and directly call controller methods such as `current_user`.
|
|
484
|
+
- When `validate` returns `false` or `nil`, the field adds an `invalid` error.
|
|
485
|
+
- `error` / `error_message`
|
|
486
|
+
- Override the error message used when that field fails validation or coercion.
|
|
487
|
+
- Supported forms:
|
|
488
|
+
- `String`
|
|
489
|
+
- `-> { }`
|
|
490
|
+
- `->(error, value) { }`
|
|
491
|
+
- Field-level `error` / `error_message` have higher priority than global `config.error_messages`.
|
|
443
492
|
|
|
444
493
|
About nested object fields
|
|
445
494
|
|
|
@@ -482,7 +531,7 @@ class UsersController < ApplicationController
|
|
|
482
531
|
end
|
|
483
532
|
```
|
|
484
533
|
|
|
485
|
-
`User.schemas` returns a hash that can be passed directly into `form data:`, `json data:`, or `body`.
|
|
534
|
+
`User.schemas` returns a symbol-keyed hash that can be passed directly into `form data:`, `json data:`, or `body`.
|
|
486
535
|
|
|
487
536
|
By default, it includes all model fields:
|
|
488
537
|
|
|
@@ -496,7 +545,36 @@ You can also limit the exported fields:
|
|
|
496
545
|
User.schemas(only: %i[name phone role])
|
|
497
546
|
```
|
|
498
547
|
|
|
499
|
-
|
|
548
|
+
Or exclude specific fields:
|
|
549
|
+
|
|
550
|
+
```ruby
|
|
551
|
+
User.schemas(except: %i[phone role])
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
When `only:` and `except:` are used together, ActionSpec applies `except:` after `only:`.
|
|
555
|
+
|
|
556
|
+
You can also extract validators for a specific validation context:
|
|
557
|
+
|
|
558
|
+
```ruby
|
|
559
|
+
User.schemas(on: :create)
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
You can also override requiredness in the exported schema:
|
|
563
|
+
When `required:` is an array, only the listed fields are treated as required, and every other exported field is treated as non-required.
|
|
564
|
+
|
|
565
|
+
```ruby
|
|
566
|
+
User.schemas(required: true)
|
|
567
|
+
User.schemas(required: false)
|
|
568
|
+
User.schemas(required: %i[name role])
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
You can also merge custom schema fragments into the exported fields:
|
|
572
|
+
|
|
573
|
+
```ruby
|
|
574
|
+
User.schemas(merge: { name: { required: false, desc: "nickname" } })
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
`bang:` defaults to `true`, so required fields are emitted as bang keys such as `name!:`. `only:` and `except:` both accept plain names or bang-style names such as `phone!`. If you prefer plain keys, you can pass `bang: false`, and required fields will be emitted as `required: true` instead:
|
|
500
578
|
|
|
501
579
|
```ruby
|
|
502
580
|
User.schemas(bang: false)
|
|
@@ -505,7 +583,8 @@ User.schemas(bang: false)
|
|
|
505
583
|
ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel when available, including:
|
|
506
584
|
|
|
507
585
|
- field type
|
|
508
|
-
- requiredness, rendered either as bang keys such as `
|
|
586
|
+
- requiredness, rendered either as bang keys such as `name!:` or as `required: true` when `bang: false`
|
|
587
|
+
- `allow_blank: false` from presence validators unless that validator explicitly allows blank
|
|
509
588
|
- enum values from `enum`
|
|
510
589
|
- `default`
|
|
511
590
|
- `desc` from column comments
|
|
@@ -513,22 +592,19 @@ ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel
|
|
|
513
592
|
- `range` from numericality validators
|
|
514
593
|
- `length` from length validators and string column limits
|
|
515
594
|
|
|
595
|
+
Conditional validators with `if:` or `unless:` are skipped during schema extraction, because they cannot be represented as unconditional static schema rules. Validators with `on:` / `except_on:` are skipped by default, but can be extracted by passing `schemas(on: ...)`. The `required:` option overrides the requiredness of exported fields only; it does not remove other extracted constraints such as `allow_blank: false`. The `merge:` option deep-merges custom fragments into each extracted field definition.
|
|
596
|
+
|
|
516
597
|
Example output:
|
|
517
598
|
|
|
518
599
|
```ruby
|
|
519
600
|
User.schemas
|
|
520
601
|
# {
|
|
521
|
-
#
|
|
522
|
-
#
|
|
523
|
-
#
|
|
602
|
+
# name!: { type: String, desc: "user name", length: { maximum: 20 } },
|
|
603
|
+
# phone!: { type: String, allow_blank: false, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
|
|
604
|
+
# role: { type: String, enum: %w[admin member visitor] }
|
|
524
605
|
# }
|
|
525
|
-
|
|
526
606
|
User.schemas(bang: false)
|
|
527
|
-
# {
|
|
528
|
-
# "name" => { type: String, required: true, desc: "user name", length: { maximum: 20 } },
|
|
529
|
-
# "phone" => { type: String, required: true, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
|
|
530
|
-
# "role" => { type: String, enum: %w[admin member visitor] }
|
|
531
|
-
# }
|
|
607
|
+
# { name: { type: String, required: true, desc: "user name", length: { maximum: 20 } }, ... }
|
|
532
608
|
```
|
|
533
609
|
|
|
534
610
|
#### Type And Boundary Matrix
|
|
@@ -729,6 +805,18 @@ ActionSpec.configure { |config|
|
|
|
729
805
|
}
|
|
730
806
|
```
|
|
731
807
|
|
|
808
|
+
If you want to override one specific field directly in the DSL, use `error` or `error_message` on that field:
|
|
809
|
+
|
|
810
|
+
```ruby
|
|
811
|
+
doc {
|
|
812
|
+
query! :page, Integer, error: "choose a page first"
|
|
813
|
+
query :role, String, validate: -> { false }, error_message: -> { "is not allowed for #{current_user}" }
|
|
814
|
+
json data: {
|
|
815
|
+
birthday!: { type: Date, error_message: ->(error, value) { "#{error}: #{value.inspect}" } }
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
```
|
|
819
|
+
|
|
732
820
|
## AI Generation Style Guide
|
|
733
821
|
|
|
734
822
|
When using AI tools to generate Rails controller code, and the change involves parameter validation, type coercion, default values, or similar parameter contracts, these conventions work well with ActionSpec:
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :invalid_parameters_exception_class, :open_api_output, :open_api_title, :open_api_version,
|
|
6
|
-
:open_api_server_url, :default_response_media_type
|
|
6
|
+
:open_api_server_url, :default_response_media_type, :required_allow_blank
|
|
7
7
|
attr_reader :error_messages
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
@@ -13,6 +13,7 @@ module ActionSpec
|
|
|
13
13
|
@open_api_version = nil
|
|
14
14
|
@open_api_server_url = nil
|
|
15
15
|
@default_response_media_type = :json
|
|
16
|
+
@required_allow_blank = true
|
|
16
17
|
@error_messages = ActiveSupport::HashWithIndifferentAccess.new
|
|
17
18
|
end
|
|
18
19
|
|
|
@@ -35,6 +36,7 @@ module ActionSpec
|
|
|
35
36
|
copy.open_api_version = open_api_version
|
|
36
37
|
copy.open_api_server_url = open_api_server_url
|
|
37
38
|
copy.default_response_media_type = default_response_media_type
|
|
39
|
+
copy.required_allow_blank = required_allow_blank
|
|
38
40
|
copy.error_messages = error_messages.deep_dup
|
|
39
41
|
end
|
|
40
42
|
end
|
|
@@ -6,32 +6,53 @@ module ActionSpec
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
class_methods do
|
|
9
|
-
def schemas(only: nil, bang: true)
|
|
10
|
-
names = selected_column_names(only)
|
|
9
|
+
def schemas(only: nil, except: nil, on: nil, required: nil, merge: nil, bang: true)
|
|
10
|
+
names = selected_column_names(only:, except:)
|
|
11
11
|
@action_spec_validator_index = build_validator_index
|
|
12
|
+
@action_spec_validation_context = normalize_validation_context(on)
|
|
13
|
+
@action_spec_required_override = normalize_required_override(required)
|
|
14
|
+
@action_spec_schema_merge = normalize_schema_merge(merge)
|
|
12
15
|
|
|
13
16
|
names.each_with_object(ActiveSupport::OrderedHash.new) do |name, hash|
|
|
14
|
-
|
|
17
|
+
base_required = required_output?(name)
|
|
18
|
+
definition = schema_definition_for(name, bang:, required: base_required)
|
|
19
|
+
definition = merge_definition_for(name, definition)
|
|
20
|
+
output_required = output_required_for(definition, bang:, fallback: base_required)
|
|
21
|
+
definition = normalize_required_in_definition(definition, bang:, required: output_required)
|
|
22
|
+
hash[output_name(name, bang:, required: output_required)] = definition
|
|
15
23
|
end
|
|
16
24
|
ensure
|
|
17
25
|
remove_instance_variable(:@action_spec_validator_index) if instance_variable_defined?(:@action_spec_validator_index)
|
|
26
|
+
remove_instance_variable(:@action_spec_validation_context) if instance_variable_defined?(:@action_spec_validation_context)
|
|
27
|
+
remove_instance_variable(:@action_spec_required_override) if instance_variable_defined?(:@action_spec_required_override)
|
|
28
|
+
remove_instance_variable(:@action_spec_schema_merge) if instance_variable_defined?(:@action_spec_schema_merge)
|
|
18
29
|
end
|
|
19
30
|
|
|
20
31
|
private
|
|
21
32
|
|
|
22
|
-
def selected_column_names(only)
|
|
23
|
-
selected =
|
|
33
|
+
def selected_column_names(only:, except:)
|
|
34
|
+
selected = selected_names(only)
|
|
35
|
+
excluded = excluded_names(except)
|
|
24
36
|
|
|
25
|
-
column_names.select { |name| selected.include?(name) }
|
|
37
|
+
column_names.select { |name| selected.include?(name) && !excluded.include?(name) }
|
|
26
38
|
end
|
|
27
39
|
|
|
28
|
-
def
|
|
29
|
-
|
|
40
|
+
def selected_names(only)
|
|
41
|
+
Array(only).presence&.map { |name| normalize_name(name) } || column_names
|
|
30
42
|
end
|
|
31
43
|
|
|
32
|
-
def
|
|
44
|
+
def excluded_names(except)
|
|
45
|
+
Array(except).map { |name| normalize_name(name) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def output_name(name, bang:, required:)
|
|
49
|
+
(bang && required ? "#{name}!" : name).to_sym
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def schema_definition_for(name, bang:, required:)
|
|
33
53
|
definition = { type: schema_type_for(name) }
|
|
34
|
-
definition[:required] = true if
|
|
54
|
+
definition[:required] = true if required && !bang
|
|
55
|
+
definition[:allow_blank] = false if blank_disallowed_by_validation?(name)
|
|
35
56
|
definition[:default] = column_default_for(name) unless column_default_for(name).nil?
|
|
36
57
|
definition[:desc] = column_comment_for(name) if column_comment_for(name).present?
|
|
37
58
|
definition[:enum] = resolved_enum_for(name) if resolved_enum_for(name).present?
|
|
@@ -59,7 +80,11 @@ module ActionSpec
|
|
|
59
80
|
end
|
|
60
81
|
|
|
61
82
|
def required_attribute?(name)
|
|
62
|
-
|
|
83
|
+
database_required?(name) || presence_requires_value?(name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def database_required?(name)
|
|
87
|
+
!column_nullable?(name) && column_default_for(name).nil?
|
|
63
88
|
end
|
|
64
89
|
|
|
65
90
|
def column_nullable?(name)
|
|
@@ -91,10 +116,8 @@ module ActionSpec
|
|
|
91
116
|
end
|
|
92
117
|
|
|
93
118
|
def range_for(name)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{}.tap do |range|
|
|
119
|
+
validators_for(name, ActiveModel::Validations::NumericalityValidator).each_with_object({}) do |validator, range|
|
|
120
|
+
options = validator.options
|
|
98
121
|
range[:gt] = options[:greater_than] if options.key?(:greater_than)
|
|
99
122
|
range[:ge] = options[:greater_than_or_equal_to] if options.key?(:greater_than_or_equal_to)
|
|
100
123
|
range[:lt] = options[:less_than] if options.key?(:less_than)
|
|
@@ -107,13 +130,15 @@ module ActionSpec
|
|
|
107
130
|
limit = string_limit_for(name)
|
|
108
131
|
length[:maximum] = limit if limit
|
|
109
132
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
133
|
+
validators_for(name, ActiveModel::Validations::LengthValidator).each do |validator|
|
|
134
|
+
options = validator.options
|
|
135
|
+
length[:minimum] = options[:minimum] if options.key?(:minimum)
|
|
136
|
+
length[:maximum] = options[:maximum] if options.key?(:maximum)
|
|
113
137
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
if options.key?(:is)
|
|
139
|
+
length[:minimum] = options[:is]
|
|
140
|
+
length[:maximum] = options[:is]
|
|
141
|
+
end
|
|
117
142
|
end
|
|
118
143
|
end
|
|
119
144
|
|
|
@@ -127,12 +152,122 @@ module ActionSpec
|
|
|
127
152
|
column.limit
|
|
128
153
|
end
|
|
129
154
|
|
|
130
|
-
def
|
|
131
|
-
|
|
155
|
+
def blank_disallowed_by_validation?(name)
|
|
156
|
+
validator = presence_validator_for(name)
|
|
157
|
+
validator.present? && !validator_allows_blank?(validator)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def presence_requires_value?(name)
|
|
161
|
+
validator = presence_validator_for(name)
|
|
162
|
+
validator.present? && !validator_allows_blank?(validator) && !validator_allows_nil?(validator)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def presence_validator_for(name)
|
|
166
|
+
validator_for(name, ActiveModel::Validations::PresenceValidator)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validator_allows_blank?(validator)
|
|
170
|
+
validator.options[:allow_blank] == true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validator_allows_nil?(validator)
|
|
174
|
+
validator.options[:allow_nil] == true
|
|
132
175
|
end
|
|
133
176
|
|
|
134
177
|
def validator_for(name, klass)
|
|
135
|
-
|
|
178
|
+
validators_for(name, klass).first
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validators_for(name, klass)
|
|
182
|
+
validator_index.fetch(name.to_s, []).select do |validator|
|
|
183
|
+
validator.is_a?(klass) && static_validator?(validator)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def static_validator?(validator)
|
|
188
|
+
%i[if unless].none? { |option| validator.options.key?(option) } &&
|
|
189
|
+
validation_context_matches?(validator)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validation_context_matches?(validator)
|
|
193
|
+
return false if requested_validation_context.nil? && validator.options.key?(:on)
|
|
194
|
+
return false if requested_validation_context.nil? && validator.options.key?(:except_on)
|
|
195
|
+
|
|
196
|
+
matches_validation_context?(validator) && allowed_by_except_on?(validator)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def matches_validation_context?(validator)
|
|
200
|
+
return true unless validator.options.key?(:on)
|
|
201
|
+
|
|
202
|
+
validator_contexts_for(validator.options[:on]).include?(requested_validation_context)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def allowed_by_except_on?(validator)
|
|
206
|
+
return true unless validator.options.key?(:except_on)
|
|
207
|
+
|
|
208
|
+
!validator_contexts_for(validator.options[:except_on]).include?(requested_validation_context)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def validator_contexts_for(value)
|
|
212
|
+
Array(value).filter_map { |entry| normalize_validation_context(entry) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def normalize_validation_context(value)
|
|
216
|
+
value.present? ? value.to_sym : nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def requested_validation_context
|
|
220
|
+
@action_spec_validation_context
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def required_output?(name)
|
|
224
|
+
override = @action_spec_required_override
|
|
225
|
+
return required_attribute?(name) if override.nil?
|
|
226
|
+
return override if override == true || override == false
|
|
227
|
+
|
|
228
|
+
override.include?(name.to_s)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def normalize_required_override(value)
|
|
232
|
+
return if value.nil?
|
|
233
|
+
return value if value == true || value == false
|
|
234
|
+
|
|
235
|
+
Array(value).map { |name| normalize_name(name) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def merge_definition_for(name, definition)
|
|
239
|
+
fragment = @action_spec_schema_merge.fetch(name.to_s, nil)
|
|
240
|
+
return definition unless fragment
|
|
241
|
+
|
|
242
|
+
definition.deep_merge(fragment)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def output_required_for(definition, bang:, fallback:)
|
|
246
|
+
return fallback if bang && !definition.key?(:required)
|
|
247
|
+
|
|
248
|
+
definition.fetch(:required, fallback) == true
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def normalize_required_in_definition(definition, bang:, required:)
|
|
252
|
+
definition = definition.deep_dup
|
|
253
|
+
if bang
|
|
254
|
+
definition.delete(:required)
|
|
255
|
+
else
|
|
256
|
+
required ? definition[:required] = true : definition.delete(:required)
|
|
257
|
+
end
|
|
258
|
+
definition
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def normalize_schema_merge(value)
|
|
262
|
+
return {} if value.nil?
|
|
263
|
+
|
|
264
|
+
value.to_h.each_with_object({}) do |(name, fragment), hash|
|
|
265
|
+
hash[normalize_name(name)] = normalize_schema_fragment(fragment)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def normalize_schema_fragment(fragment)
|
|
270
|
+
fragment.to_h.deep_symbolize_keys
|
|
136
271
|
end
|
|
137
272
|
|
|
138
273
|
def normalize_name(name)
|
|
@@ -10,16 +10,20 @@ module ActionSpec
|
|
|
10
10
|
@item = item
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def cast(value, context:, coerce:, result:, path:)
|
|
13
|
+
def cast(value, context:, coerce:, result:, path:, field: nil)
|
|
14
14
|
unless value.is_a?(Array)
|
|
15
|
-
|
|
15
|
+
if field
|
|
16
|
+
field.add_error(result, path:, type: :invalid, value:, context:)
|
|
17
|
+
else
|
|
18
|
+
result.add_error(path.join("."), :invalid)
|
|
19
|
+
end
|
|
16
20
|
return []
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
output = value.each_with_index.map do |entry, index|
|
|
20
|
-
item.cast(entry, context:, coerce:, result:, path: [*path, index])
|
|
24
|
+
item.cast(entry, context:, coerce:, result:, path: [*path, index], field: nil)
|
|
21
25
|
end
|
|
22
|
-
validate_constraints(output, result:, path:)
|
|
26
|
+
validate_constraints(output, result:, path:, field:, context:)
|
|
23
27
|
output
|
|
24
28
|
end
|
|
25
29
|
|
|
@@ -24,16 +24,20 @@ module ActionSpec
|
|
|
24
24
|
Schema::Missing
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def blank_value(_value)
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
27
31
|
def blank_allowed?
|
|
28
32
|
blank != false
|
|
29
33
|
end
|
|
30
34
|
|
|
31
|
-
def validate_constraints(value, result:, path:)
|
|
35
|
+
def validate_constraints(value, result:, path:, field: nil, context: nil)
|
|
32
36
|
return if value.nil?
|
|
33
37
|
|
|
34
|
-
validate_enum(value, result:, path:)
|
|
35
|
-
validate_range(value, result:, path:)
|
|
36
|
-
validate_pattern(value, result:, path:)
|
|
38
|
+
validate_enum(value, result:, path:, field:, context:)
|
|
39
|
+
validate_range(value, result:, path:, field:, context:)
|
|
40
|
+
validate_pattern(value, result:, path:, field:, context:)
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
def copy
|
|
@@ -46,34 +50,36 @@ module ActionSpec
|
|
|
46
50
|
|
|
47
51
|
private
|
|
48
52
|
|
|
49
|
-
def add_error(result, path, type, **options)
|
|
53
|
+
def add_error(result, path, type, field: nil, value: nil, context: nil, **options)
|
|
54
|
+
return field.add_error(result, path:, type:, value:, context:, **options) if field
|
|
55
|
+
|
|
50
56
|
result.add_error(path.join("."), type, **options)
|
|
51
57
|
end
|
|
52
58
|
|
|
53
|
-
def validate_enum(value, result:, path:)
|
|
59
|
+
def validate_enum(value, result:, path:, field:, context:)
|
|
54
60
|
return if enum.blank?
|
|
55
61
|
return if Array(enum).include?(value)
|
|
56
62
|
|
|
57
|
-
add_error(result, path, :inclusion)
|
|
63
|
+
add_error(result, path, :inclusion, field:, value:, context:)
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
def validate_range(value, result:, path:)
|
|
66
|
+
def validate_range(value, result:, path:, field:, context:)
|
|
61
67
|
return if range.blank?
|
|
62
68
|
|
|
63
69
|
rules = range.symbolize_keys
|
|
64
|
-
add_error(result, path, :greater_than_or_equal_to, count: rules[:ge]) if rules.key?(:ge) && value < rules[:ge]
|
|
65
|
-
add_error(result, path, :greater_than, count: rules[:gt]) if rules.key?(:gt) && value <= rules[:gt]
|
|
66
|
-
add_error(result, path, :less_than_or_equal_to, count: rules[:le]) if rules.key?(:le) && value > rules[:le]
|
|
67
|
-
add_error(result, path, :less_than, count: rules[:lt]) if rules.key?(:lt) && value >= rules[:lt]
|
|
70
|
+
add_error(result, path, :greater_than_or_equal_to, field:, value:, context:, count: rules[:ge]) if rules.key?(:ge) && value < rules[:ge]
|
|
71
|
+
add_error(result, path, :greater_than, field:, value:, context:, count: rules[:gt]) if rules.key?(:gt) && value <= rules[:gt]
|
|
72
|
+
add_error(result, path, :less_than_or_equal_to, field:, value:, context:, count: rules[:le]) if rules.key?(:le) && value > rules[:le]
|
|
73
|
+
add_error(result, path, :less_than, field:, value:, context:, count: rules[:lt]) if rules.key?(:lt) && value >= rules[:lt]
|
|
68
74
|
end
|
|
69
75
|
|
|
70
|
-
def validate_pattern(value, result:, path:)
|
|
76
|
+
def validate_pattern(value, result:, path:, field:, context:)
|
|
71
77
|
return if pattern.blank?
|
|
72
78
|
|
|
73
79
|
matcher = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
|
|
74
80
|
return if value.to_s.match?(matcher)
|
|
75
81
|
|
|
76
|
-
add_error(result, path, :invalid)
|
|
82
|
+
add_error(result, path, :invalid, field:, value:, context:)
|
|
77
83
|
end
|
|
78
84
|
end
|
|
79
85
|
end
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
module Schema
|
|
5
5
|
class Field
|
|
6
|
-
attr_reader :name, :schema, :transform, :validate, :px_key, :scopes
|
|
6
|
+
attr_reader :name, :schema, :transform, :validate, :px_key, :scopes, :error_message
|
|
7
7
|
|
|
8
|
-
def initialize(name:, required:, schema:, transform: nil, validate: nil, px_key: nil, scopes: [])
|
|
8
|
+
def initialize(name:, required:, schema:, transform: nil, validate: nil, px_key: nil, scopes: [], error_message: nil)
|
|
9
9
|
@name = name.to_sym
|
|
10
10
|
@required = required
|
|
11
11
|
@schema = schema
|
|
@@ -13,6 +13,7 @@ module ActionSpec
|
|
|
13
13
|
@validate = validate
|
|
14
14
|
@px_key = px_key&.to_sym
|
|
15
15
|
@scopes = Array(scopes).map(&:to_sym).freeze
|
|
16
|
+
@error_message = error_message
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def required?
|
|
@@ -50,12 +51,25 @@ module ActionSpec
|
|
|
50
51
|
validate.present? || schema.custom_validation?
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
def add_error(result, path:, type:, value:, context: nil, **options)
|
|
55
|
+
result.add_error(path.join("."), type, message: resolve_error_message(type, value, context:), **options)
|
|
56
|
+
end
|
|
57
|
+
|
|
53
58
|
def copy
|
|
54
|
-
self.class.new(name:, required: required?, schema: schema.copy, transform:, validate:, px_key:, scopes:)
|
|
59
|
+
self.class.new(name:, required: required?, schema: schema.copy, transform:, validate:, px_key:, scopes:, error_message:)
|
|
55
60
|
end
|
|
56
61
|
|
|
57
62
|
private
|
|
58
63
|
|
|
64
|
+
def resolve_error_message(type, value, context:)
|
|
65
|
+
case error_message
|
|
66
|
+
when nil then nil
|
|
67
|
+
when String then error_message
|
|
68
|
+
when Proc then apply_error_proc(type, value, context:)
|
|
69
|
+
else error_message.to_s
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
59
73
|
def apply_symbol_transform(value, context:)
|
|
60
74
|
symbol = transform.to_sym
|
|
61
75
|
return value.public_send(symbol) if value.respond_to?(symbol)
|
|
@@ -80,6 +94,16 @@ module ActionSpec
|
|
|
80
94
|
validate.call(value)
|
|
81
95
|
end
|
|
82
96
|
|
|
97
|
+
def apply_error_proc(type, value, context:)
|
|
98
|
+
return context.instance_exec(&error_message) if context && error_message.arity.zero?
|
|
99
|
+
return context.instance_exec(type, &error_message) if context && error_message.arity == 1
|
|
100
|
+
return context.instance_exec(type, value, &error_message) if context && (error_message.arity == 2 || error_message.arity.negative?)
|
|
101
|
+
return error_message.call if error_message.arity.zero?
|
|
102
|
+
return error_message.call(type) if error_message.arity == 1
|
|
103
|
+
|
|
104
|
+
error_message.call(type, value)
|
|
105
|
+
end
|
|
106
|
+
|
|
83
107
|
def invoke_context_transform(context, symbol, value)
|
|
84
108
|
method = context.method(symbol)
|
|
85
109
|
return context.public_send(symbol) if method.arity.zero?
|
|
@@ -10,8 +10,8 @@ module ActionSpec
|
|
|
10
10
|
@fields = fields
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def cast(value, context:, coerce:, result:, path:)
|
|
14
|
-
source = normalize_source(value, result:, path:)
|
|
13
|
+
def cast(value, context:, coerce:, result:, path:, field: nil)
|
|
14
|
+
source = normalize_source(value, result:, path:, field:, context:, invalid_value: value)
|
|
15
15
|
return Schema::Missing if source.equal?(Schema::Missing)
|
|
16
16
|
|
|
17
17
|
output = ActiveSupport::HashWithIndifferentAccess.new
|
|
@@ -30,7 +30,7 @@ module ActionSpec
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def materialize_missing(context:, coerce:, result:, path:)
|
|
33
|
-
cast({}, context:, coerce:, result:, path:)
|
|
33
|
+
cast({}, context:, coerce:, result:, path:, field: nil)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def copy
|
|
@@ -43,12 +43,16 @@ module ActionSpec
|
|
|
43
43
|
|
|
44
44
|
private
|
|
45
45
|
|
|
46
|
-
def normalize_source(value, result:, path:)
|
|
46
|
+
def normalize_source(value, result:, path:, field:, context:, invalid_value:)
|
|
47
47
|
return {} if value.nil?
|
|
48
48
|
return value.to_unsafe_h.with_indifferent_access if value.is_a?(ActionController::Parameters)
|
|
49
49
|
return value.with_indifferent_access if value.is_a?(Hash)
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
if field
|
|
52
|
+
field.add_error(result, path:, type: :invalid, value: invalid_value, context:)
|
|
53
|
+
else
|
|
54
|
+
result.add_error(path.join("."), :invalid)
|
|
55
|
+
end
|
|
52
56
|
Schema::Missing
|
|
53
57
|
end
|
|
54
58
|
end
|
|
@@ -12,13 +12,14 @@ module ActionSpec
|
|
|
12
12
|
@path = [*path, field.name]
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
def resolve
|
|
16
|
+
return resolve_missing unless present?
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
return resolve_nil if value.nil?
|
|
19
|
+
return finalize(schema.blank_value(value)) if blank_string_allowed?
|
|
20
|
+
return resolve_blank if blank_disallowed?
|
|
20
21
|
|
|
21
|
-
finalize(schema.cast(value, context:, coerce:, result:, path:))
|
|
22
|
+
finalize(schema.cast(value, context:, coerce:, result:, path:, field:))
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
private
|
|
@@ -39,22 +40,22 @@ module ActionSpec
|
|
|
39
40
|
|
|
40
41
|
def resolve_missing
|
|
41
42
|
if schema.default.respond_to?(:call)
|
|
42
|
-
return finalize(schema.cast(evaluate_default(schema.default), context:, coerce:, result:, path:))
|
|
43
|
+
return finalize(schema.cast(evaluate_default(schema.default), context:, coerce:, result:, path:, field:))
|
|
43
44
|
end
|
|
44
|
-
return finalize(schema.cast(schema.default, context:, coerce:, result:, path:)) unless schema.default.nil?
|
|
45
|
+
return finalize(schema.cast(schema.default, context:, coerce:, result:, path:, field:)) unless schema.default.nil?
|
|
45
46
|
return finalize(schema.materialize_missing(context:, coerce:, result:, path:)) unless field.required?
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
field.add_error(result, path:, type: :required, value: nil, context:)
|
|
48
49
|
Schema::Missing
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
def resolve_nil
|
|
52
|
-
|
|
53
|
+
field.add_error(result, path:, type: field.required? ? :required : :blank, value:, context:)
|
|
53
54
|
Schema::Missing
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def resolve_blank
|
|
57
|
-
|
|
58
|
+
field.add_error(result, path:, type: :blank, value:, context:)
|
|
58
59
|
Schema::Missing
|
|
59
60
|
end
|
|
60
61
|
|
|
@@ -62,6 +63,12 @@ module ActionSpec
|
|
|
62
63
|
!schema.blank_allowed? && value.respond_to?(:blank?) && value.blank?
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
def blank_string_allowed?
|
|
67
|
+
# Keep blank-string semantics explicit: preserve only values that the schema
|
|
68
|
+
# can meaningfully carry as blank, and normalize the rest to nil.
|
|
69
|
+
schema.blank_allowed? && value.is_a?(String) && value.blank?
|
|
70
|
+
end
|
|
71
|
+
|
|
65
72
|
def finalize(resolved)
|
|
66
73
|
return resolved if resolved.equal?(Schema::Missing)
|
|
67
74
|
|
|
@@ -10,21 +10,29 @@ module ActionSpec
|
|
|
10
10
|
@type = type
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def cast(value, context: nil, coerce:, result:, path:)
|
|
13
|
+
def cast(value, context: nil, coerce:, result:, path:, field: nil)
|
|
14
14
|
candidate = TypeCaster.cast(type, value)
|
|
15
15
|
rescue TypeCaster::CastError => error
|
|
16
|
-
|
|
16
|
+
if field
|
|
17
|
+
field.add_error(result, path:, type: :invalid_type, value:, context:, expected: error.expected)
|
|
18
|
+
else
|
|
19
|
+
result.add_error(path.join("."), :invalid_type, expected: error.expected)
|
|
20
|
+
end
|
|
17
21
|
Schema::Missing
|
|
18
22
|
else
|
|
19
23
|
return candidate if candidate.nil?
|
|
20
24
|
|
|
21
|
-
validate_constraints(candidate, result:, path:)
|
|
25
|
+
validate_constraints(candidate, result:, path:, field:, context:)
|
|
22
26
|
coerce ? candidate : value
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def copy
|
|
26
30
|
self.class.new(type, default:, enum:, range:, pattern:, length:, blank:, desc: description, example:, examples:)
|
|
27
31
|
end
|
|
32
|
+
|
|
33
|
+
def blank_value(value)
|
|
34
|
+
TypeCaster.normalize(type) == :string ? value : nil
|
|
35
|
+
end
|
|
28
36
|
end
|
|
29
37
|
end
|
|
30
38
|
end
|
data/lib/action_spec/schema.rb
CHANGED
|
@@ -13,7 +13,7 @@ module ActionSpec
|
|
|
13
13
|
module Schema
|
|
14
14
|
Missing = Object.new.freeze
|
|
15
15
|
OPTION_KEYS = %i[default desc enum range pattern length blank allow_blank example examples].freeze
|
|
16
|
-
FIELD_OPTION_KEYS = (OPTION_KEYS + %i[required transform px px_key validate]).freeze
|
|
16
|
+
FIELD_OPTION_KEYS = (OPTION_KEYS + %i[required transform px px_key validate error error_message]).freeze
|
|
17
17
|
|
|
18
18
|
class << self
|
|
19
19
|
def build(type = nil, **options)
|
|
@@ -23,12 +23,15 @@ module ActionSpec
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def build_field(name, definition = nil, required: false, scopes: [])
|
|
26
|
+
field_required = required_key?(name) || required || explicit_required?(definition)
|
|
27
|
+
|
|
26
28
|
Field.new(
|
|
27
29
|
name: field_name(name),
|
|
28
|
-
required:
|
|
29
|
-
schema: build_field_schema(
|
|
30
|
+
required: field_required,
|
|
31
|
+
schema: build_field_schema(schema_definition_for_field(definition, required: field_required)),
|
|
30
32
|
transform: explicit_transform(definition),
|
|
31
33
|
validate: explicit_validate(definition),
|
|
34
|
+
error_message: explicit_error_message(definition),
|
|
32
35
|
px_key: explicit_px_key(definition),
|
|
33
36
|
scopes:
|
|
34
37
|
)
|
|
@@ -76,8 +79,8 @@ module ActionSpec
|
|
|
76
79
|
name.to_s.end_with?("!")
|
|
77
80
|
end
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
def build_field_schema(definition)
|
|
83
|
+
return from_definition(type: definition) unless definition.is_a?(Hash)
|
|
81
84
|
|
|
82
85
|
definition = definition.symbolize_keys
|
|
83
86
|
return from_definition(definition.except(:required)) if definition.key?(:type)
|
|
@@ -128,6 +131,13 @@ module ActionSpec
|
|
|
128
131
|
normalize_px_key(options[:px_key] || options[:px])
|
|
129
132
|
end
|
|
130
133
|
|
|
134
|
+
def explicit_error_message(definition)
|
|
135
|
+
return unless definition.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
options = definition.symbolize_keys
|
|
138
|
+
options[:error_message] || options[:error]
|
|
139
|
+
end
|
|
140
|
+
|
|
131
141
|
def normalize_px_key(value)
|
|
132
142
|
return if value.nil? || value == true || value == false
|
|
133
143
|
|
|
@@ -137,7 +147,23 @@ module ActionSpec
|
|
|
137
147
|
def strip_field_options(definition)
|
|
138
148
|
return definition unless definition.is_a?(Hash)
|
|
139
149
|
|
|
140
|
-
definition.symbolize_keys.except(:required, :transform, :px, :px_key, :validate)
|
|
150
|
+
definition.symbolize_keys.except(:required, :transform, :px, :px_key, :validate, :error, :error_message)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def schema_definition_for_field(definition, required:)
|
|
154
|
+
definition = strip_field_options(definition)
|
|
155
|
+
return definition unless required
|
|
156
|
+
return definition if explicit_blank_option?(definition)
|
|
157
|
+
|
|
158
|
+
if definition.is_a?(Hash)
|
|
159
|
+
definition.symbolize_keys.merge(allow_blank: ActionSpec.config.required_allow_blank)
|
|
160
|
+
else
|
|
161
|
+
{ type: definition.nil? ? String : definition, allow_blank: ActionSpec.config.required_allow_blank }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def explicit_blank_option?(definition)
|
|
166
|
+
definition.is_a?(Hash) && definition.symbolize_keys.slice(:blank, :allow_blank).present?
|
|
141
167
|
end
|
|
142
168
|
end
|
|
143
169
|
end
|
|
@@ -40,7 +40,9 @@ module ActionSpec
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def add_error(attribute, type, **options)
|
|
43
|
+
def add_error(attribute, type, message: nil, **options)
|
|
44
|
+
return errors.add(attribute, message) if message
|
|
45
|
+
|
|
44
46
|
if (message = ActionSpec.config.message_for(attribute, type, options))
|
|
45
47
|
errors.add(attribute, message)
|
|
46
48
|
else
|
|
@@ -133,7 +133,7 @@ module ActionSpec
|
|
|
133
133
|
validate_nested_schema!(field.schema, value, result:, path:)
|
|
134
134
|
return if field.validate_value(value, context: controller)
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
field.add_error(result, path:, type: :invalid, value:, context: controller)
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
def validate_nested_schema!(schema, value, result:, path:)
|
data/lib/action_spec/version.rb
CHANGED