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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9218555ca7d33709a7a5b22528f6900358e36247c123e521f2342d7a36ac02b5
4
- data.tar.gz: e771af58669e707ccbcc922b6d9afa726a2b25817ab4836e01ce1154bd22344b
3
+ metadata.gz: d4483d08f3dbf8917159d539ee9b064366551409ae646714def0b89595484428
4
+ data.tar.gz: 065af0d1f835124179afef0a756515b6184598cd35ef217a96615f18b2098cbe
5
5
  SHA512:
6
- metadata.gz: 7d03ee8f19de7c1c874302344eadf224f9aa3fba2dab228f2f4b44448a2865ba53dd97385587b6b9691308e4a987048b1eab6e5f55a1b03ef93424a643857a0c
7
- data.tar.gz: 357fe37dc9a591740079c76dd3e4238a9485cb0d1e57ab020bed09bf737d1937ef22b777f8b062f78473fe497965c2df754ee3858a97f77bdd59674002cdda69
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`". It does not reject blank strings by itself. If you want to reject blank values, use `blank: false` or `allow_blank: false`.
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 # or allow_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 :request_id, String, px_key: :trace_id
433
- query :end_at, Integer, validate: -> { it >= px[:start_at] }
444
+ query :end_at, Integer, validate: -> { current_user && it >= px[:start_at] }
445
+ query :birthday, Date, error: "birthday error"
434
446
  ```
435
447
 
436
- Notes:
437
-
438
- - `transform` accepts a `Symbol` or a `Proc` and runs **after coercion**, before the value is written into `px`
439
- - `px` and `px_key` customize the key name written into `px`; `px` is the short form of `px_key`
440
- - `validate` accepts a `Proc` and runs **after all parameters have been resolved, coerced, transformed, and written into `px`**
441
- - `validate` runs in the current controller context, so it can read `px` and call methods such as `current_user`
442
- - when `validate` returns `false` or `nil`, the field adds an `invalid` error
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
- `bang:` defaults to `true`, so required fields are emitted as bang keys such as `"name!"`. If you prefer plain keys, you can pass `bang: false`, and required fields will be emitted as `required: true` instead:
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 `"name!"` or as `required: true` when `bang: false`
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
- # "name!" => { type: String, desc: "user name", length: { maximum: 20 } },
522
- # "phone!" => { type: String, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
523
- # "role" => { type: String, enum: %w[admin member visitor] }
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
- hash[output_name(name, bang:)] = schema_definition_for(name, bang:)
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 = Array(only).presence&.map { |name| normalize_name(name) } || column_names
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 output_name(name, bang:)
29
- bang && required_attribute?(name) ? "#{name}!" : name
40
+ def selected_names(only)
41
+ Array(only).presence&.map { |name| normalize_name(name) } || column_names
30
42
  end
31
43
 
32
- def schema_definition_for(name, bang:)
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 required_attribute?(name) && !bang
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
- (!column_nullable?(name) && column_default_for(name).nil?) || presence_validated?(name)
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
- options = validator_for(name, ActiveModel::Validations::NumericalityValidator)&.options
95
- return if options.blank?
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
- options = validator_for(name, ActiveModel::Validations::LengthValidator)&.options || {}
111
- length[:minimum] = options[:minimum] if options.key?(:minimum)
112
- length[:maximum] = options[:maximum] if options.key?(:maximum)
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
- if options.key?(:is)
115
- length[:minimum] = options[:is]
116
- length[:maximum] = options[:is]
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 presence_validated?(name)
131
- validator_for(name, ActiveModel::Validations::PresenceValidator).present?
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
- validator_index.fetch(name.to_s, []).find { |validator| validator.is_a?(klass) }
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
- result.add_error(path.join("."), :invalid)
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
- result.add_error(path.join("."), :invalid)
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
- def resolve
16
- return resolve_missing unless present?
15
+ def resolve
16
+ return resolve_missing unless present?
17
17
 
18
- return resolve_nil if value.nil?
19
- return resolve_blank if blank_disallowed?
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
- result.add_error(path.join("."), :required)
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
- result.add_error(path.join("."), field.required? ? :required : :blank)
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
- result.add_error(path.join("."), :blank)
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
- result.add_error(path.join("."), :invalid_type, expected: error.expected)
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
@@ -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: required_key?(name) || required || explicit_required?(definition),
29
- schema: build_field_schema(strip_field_options(definition)),
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
- def build_field_schema(definition)
80
- return from_definition(type: definition) unless definition.is_a?(Hash)
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
- result.add_error(path.join("."), :invalid)
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:)
@@ -1,3 +1,3 @@
1
1
  module ActionSpec
2
- VERSION = "1.5.0"
2
+ VERSION = "1.6.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: action_spec
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhandao