action_spec 1.5.0 → 1.7.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: 06b66175d9094e2a53ff6c65cdfd88ad24f91724535ff8e0152341e079d18d0b
4
+ data.tar.gz: 562f73d96c1f1d57a72ec04e866a14a14fbcf0e12df757ed3b9a334a977450cd
5
5
  SHA512:
6
- metadata.gz: 7d03ee8f19de7c1c874302344eadf224f9aa3fba2dab228f2f4b44448a2865ba53dd97385587b6b9691308e4a987048b1eab6e5f55a1b03ef93424a643857a0c
7
- data.tar.gz: 357fe37dc9a591740079c76dd3e4238a9485cb0d1e57ab020bed09bf737d1937ef22b777f8b062f78473fe497965c2df754ee3858a97f77bdd59674002cdda69
6
+ metadata.gz: 581a39acd1d534f2c81647d8f9877423bcb53e6ea2ef0deab20c071c88a9fd160351721591e8af4007f2d9543c8375089017247a2a48a1314cb76421d40f08c5
7
+ data.tar.gz: 8db69ee15a9ac6cf205e8ce40563456f498cb6a2b31ee87104ce1fe524fc33efe46caf11fb151d114380c599ecd3060eaa910185d1915ed6e42e9959186a8af6
data/README.md CHANGED
@@ -10,8 +10,9 @@ Concise and Powerful API Documentation Solution for Rails. [中文](README_zh.md
10
10
 
11
11
  ## Table Of Contents
12
12
 
13
- 1. [OpenAPI Generation](#openapi-generation)
14
- 2. [Doc DSL](#doc-dsl)
13
+ 1. [AI Agent Quick Reference](#ai-agent-quick-reference)
14
+ 2. [OpenAPI Generation](#openapi-generation)
15
+ 3. [Doc DSL](#doc-dsl)
15
16
  1. [`doc`](#doc)
16
17
  2. [`doc_dry`](#doc_dry)
17
18
  3. [DSL Inside `doc`](#dsl-inside-doc)
@@ -20,21 +21,21 @@ Concise and Powerful API Documentation Solution for Rails. [中文](README_zh.md
20
21
  3. [`openapi false`](#openapi-false)
21
22
  4. [Scope](#scope)
22
23
  5. [Response](#response)
23
- 3. [Schemas](#schemas)
24
+ 4. [Schemas](#schemas)
24
25
  1. [Declare A Required Field](#declare-a-required-field)
25
26
  2. [Field Types](#field-types)
26
27
  3. [Field Options](#field-options)
27
28
  4. [Schemas From ActiveRecord](#schemas-from-activerecord)
28
29
  5. [Type And Boundary Matrix](#type-and-boundary-matrix)
29
- 4. [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
30
+ 5. [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
30
31
  1. [Validation Flow](#validation-flow)
31
32
  2. [Reading Processed Values With `px`](#reading-processed-values-with-px)
32
33
  3. [`px` is Whitelist Extraction](#px-is-whitelist-extraction)
33
34
  4. [Errors](#errors)
34
- 5. [Configuration And I18n](#configuration-and-i18n)
35
+ 6. [Configuration And I18n](#configuration-and-i18n)
35
36
  1. [Configuration](#configuration)
36
37
  2. [I18n](#i18n)
37
- 6. [AI Generation Style Guide](#ai-generation-style-guide)
38
+ 7. [AI Generation Style Guide](#ai-generation-style-guide)
38
39
 
39
40
  ## Example
40
41
 
@@ -71,6 +72,18 @@ class UsersController < ApplicationController
71
72
  end
72
73
  ```
73
74
 
75
+ ## AI Agent Quick Reference
76
+
77
+ When generating Rails controller code with ActionSpec, use these as the canonical choices:
78
+
79
+ - put `doc { }` or `doc("Summary") { }` immediately above the action method and let ActionSpec infer the action name
80
+ - use `{ }` blocks inside `doc`
81
+ - prefer bang required syntax, such as `query! :id, Integer` and `name!: String`; keep `required: true` for compatibility or generated schemas
82
+ - fold simple nested hash fields, `data: { }`, or `in_xxx(...)` declarations into one line when they have 2 fields or fewer and no complex nesting, such as `json data: { name: String, age: Integer }` or `in_query(name: String, value: String)`
83
+ - declare body fields as `json data: { name!: String }` or `form data: { avatar!: File }`
84
+ - use `doc_dry`, `scope`, `transform`, `px` / `px_key`, `.schemas`, and `px.slice` to keep controller actions small
85
+ - rely on ActionSpec for parameter validation, type coercion, defaults, and similar contracts instead of rewriting the same parameter handling by hand
86
+
74
87
  ## Installation
75
88
 
76
89
  ```ruby
@@ -142,7 +155,7 @@ def create
142
155
  end
143
156
  ```
144
157
 
145
- You can also bind it explicitly when you want the action name declared in place:
158
+ Escape hatch: bind the action explicitly when the inferred next method is not the intended action:
146
159
 
147
160
  ```ruby
148
161
  doc(:create, "Create user") {
@@ -201,7 +214,17 @@ cookie! :remember_token, String
201
214
 
202
215
  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
216
 
204
- If you prefer not to use bang methods, you can also write `required: true`:
217
+ You can also change that default globally:
218
+
219
+ ```ruby
220
+ ActionSpec.configure do |config|
221
+ config.required_allow_blank = false
222
+ end
223
+ ```
224
+
225
+ With `required_allow_blank = false`, required fields reject blank strings unless that field explicitly sets `blank:` or `allow_blank:`.
226
+
227
+ Compatibility alternative: if you prefer not to use bang methods, you can also write `required: true`:
205
228
 
206
229
  ```ruby
207
230
  query :page, Integer, required: true
@@ -275,7 +298,16 @@ Notes:
275
298
  You can also opt an action out of OpenAPI generation from either `doc` or `doc_dry`:
276
299
 
277
300
  ```ruby
278
- openapi false
301
+ doc(openapi: false) { }
302
+ doc_dry(:index, openapi: false)
303
+ ```
304
+
305
+ Or inside the block:
306
+
307
+ ```ruby
308
+ doc {
309
+ openapi false
310
+ }
279
311
  ```
280
312
 
281
313
  #### Scope
@@ -371,7 +403,9 @@ Meaning of `!`:
371
403
 
372
404
  You can also use `required: true` instead of bang syntax for parameters, nested fields, and the root request body.
373
405
 
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`.
406
+ `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:`.
407
+
408
+ 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
409
 
376
410
  #### Field Types
377
411
 
@@ -425,21 +459,59 @@ query :today, Date, default: -> { Time.current.to_date }
425
459
  query :status, String, enum: %w[draft published]
426
460
  query :score, Integer, range: { ge: 1, le: 5 }
427
461
  query :slug, String, pattern: /\A[a-z\-]+\z/
428
- query :title, String, blank: false # or allow_blank: false
462
+ query :title, String, blank: false
429
463
 
430
464
  query :nickname, String, transform: :downcase
431
465
  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] }
466
+ query :end_at, Integer, validate: -> { current_user && it >= px[:start_at] }
467
+ query :birthday, Date, error: "birthday error"
434
468
  ```
435
469
 
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
470
+ - `required`
471
+ - Marks the field as required.
472
+ - Can replace bang syntax when you do not use `name!:`.
473
+ - `default`
474
+ - Default value used when the field is missing.
475
+ - Can be a literal or `-> { }`.
476
+ - `enum`
477
+ - Restricts the field to values from a fixed set.
478
+ - `range`
479
+ - Numeric range constraints.
480
+ - Available: `ge` / `gt` / `le` / `lt`
481
+ - `pattern`
482
+ - Regex constraint.
483
+ - `length`
484
+ - Length constraints.
485
+ - Available: `minimum` / `maximum` / `is`
486
+ - `blank` / `allow_blank`
487
+ - Controls whether blank values are allowed.
488
+ - For fields such as `Date`, if blank is allowed, no type coercion is applied and the value becomes `nil`.
489
+
490
+ - `desc`
491
+ - Used only for OpenAPI description generation.
492
+ - `example`
493
+ - Used only for generating a single OpenAPI example.
494
+ - `examples`
495
+ - Used only for generating multiple OpenAPI examples.
496
+
497
+ - `transform`
498
+ - Applies one more custom transformation to the **already-coerced value**.
499
+ - Accepts a `Symbol` or a `Proc`.
500
+ - `transform` does not run when the field does not successfully resolve to a value, such as when it is missing, `nil`, or already rejected by an earlier validation step.
501
+ - `px` / `px_key`
502
+ - Customize the key name used when the parameter is written into `px`.
503
+ - `validate`
504
+ - Accepts a `Proc`.
505
+ - Runs **after all parameters have finished resolving, coercion, transform, and writing into `px`**.
506
+ - Runs in the current controller context, so it can read `px` and directly call controller methods such as `current_user`.
507
+ - When `validate` returns `false` or `nil`, the field adds an `invalid` error.
508
+ - `error` / `error_message`
509
+ - Override the error message used when that field fails validation or coercion.
510
+ - Supported forms:
511
+ - `String`
512
+ - `-> { }`
513
+ - `->(error, value) { }`
514
+ - Field-level `error` / `error_message` have higher priority than global `config.error_messages`.
443
515
 
444
516
  About nested object fields
445
517
 
@@ -482,7 +554,7 @@ class UsersController < ApplicationController
482
554
  end
483
555
  ```
484
556
 
485
- `User.schemas` returns a hash that can be passed directly into `form data:`, `json data:`, or `body`.
557
+ `User.schemas` returns a symbol-keyed hash that can be passed directly into `form data:`, `json data:`, or `body`.
486
558
 
487
559
  By default, it includes all model fields:
488
560
 
@@ -496,7 +568,36 @@ You can also limit the exported fields:
496
568
  User.schemas(only: %i[name phone role])
497
569
  ```
498
570
 
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:
571
+ Or exclude specific fields:
572
+
573
+ ```ruby
574
+ User.schemas(except: %i[phone role])
575
+ ```
576
+
577
+ When `only:` and `except:` are used together, ActionSpec applies `except:` after `only:`.
578
+
579
+ You can also extract validators for a specific validation context:
580
+
581
+ ```ruby
582
+ User.schemas(on: :create)
583
+ ```
584
+
585
+ You can also override requiredness in the exported schema:
586
+ When `required:` is an array, only the listed fields are treated as required, and every other exported field is treated as non-required.
587
+
588
+ ```ruby
589
+ User.schemas(required: true)
590
+ User.schemas(required: false)
591
+ User.schemas(required: %i[name role])
592
+ ```
593
+
594
+ You can also merge custom schema fragments into the exported fields:
595
+
596
+ ```ruby
597
+ User.schemas(merge: { name: { required: false, desc: "nickname" } })
598
+ ```
599
+
600
+ `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
601
 
501
602
  ```ruby
502
603
  User.schemas(bang: false)
@@ -505,7 +606,8 @@ User.schemas(bang: false)
505
606
  ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel when available, including:
506
607
 
507
608
  - field type
508
- - requiredness, rendered either as bang keys such as `"name!"` or as `required: true` when `bang: false`
609
+ - requiredness, rendered either as bang keys such as `name!:` or as `required: true` when `bang: false`
610
+ - `allow_blank: false` from presence validators unless that validator explicitly allows blank
509
611
  - enum values from `enum`
510
612
  - `default`
511
613
  - `desc` from column comments
@@ -513,22 +615,19 @@ ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel
513
615
  - `range` from numericality validators
514
616
  - `length` from length validators and string column limits
515
617
 
618
+ 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.
619
+
516
620
  Example output:
517
621
 
518
622
  ```ruby
519
623
  User.schemas
520
624
  # {
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] }
625
+ # name!: { type: String, desc: "user name", length: { maximum: 20 } },
626
+ # phone!: { type: String, allow_blank: false, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
627
+ # role: { type: String, enum: %w[admin member visitor] }
524
628
  # }
525
-
526
629
  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
- # }
630
+ # { name: { type: String, required: true, desc: "user name", length: { maximum: 20 } }, ... }
532
631
  ```
533
632
 
534
633
  #### Type And Boundary Matrix
@@ -544,7 +643,7 @@ User.schemas(bang: false)
544
643
  | `DateTime` | `"2025-10-17T12:30:00Z"` | Rejects invalid datetimes such as `"2025-10-17 25:00:00"` |
545
644
  | `Time` | `"2025-10-17T12:30:00Z"` | Follows `ActiveModel::Type::Time`, so the date part becomes `2000-01-01` |
546
645
  | `File` | `ActionDispatch::Http::UploadedFile`, `Tempfile`, file-like IO objects | Keeps the object as-is and does not read file contents into memory |
547
- | `Object` | `Hash`, `ActionController::Parameters`, arbitrary Ruby objects | Passed through for scalar `Object`; nested hashes use object schema resolution |
646
+ | `Object` | `Hash`, `ActionController::Parameters` | Scalar `Object` behaves like `Hash` and rejects non-hash values; nested hashes use object schema resolution |
548
647
  | `[Type]` | arrays such as `%w[1 2 3]` for `[Integer]` | Rejects non-array values, and reports item errors like `tags.1` |
549
648
  | nested object | `{ profile: { nickname: "neo" } }` | Rejects non-hash values, and reports nested paths like `profile.nickname` |
550
649
 
@@ -729,18 +828,23 @@ ActionSpec.configure { |config|
729
828
  }
730
829
  ```
731
830
 
831
+ If you want to override one specific field directly in the DSL, use `error` or `error_message` on that field:
832
+
833
+ ```ruby
834
+ doc {
835
+ query! :page, Integer, error: "choose a page first"
836
+ query :role, String, validate: -> { false }, error: -> { "is not allowed for #{current_user}" }
837
+ json data: {
838
+ birthday!: { type: Date, error: ->(error, value) { "#{error}: #{value.inspect}" } }
839
+ }
840
+ }
841
+ ```
842
+
732
843
  ## AI Generation Style Guide
733
844
 
734
- 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:
845
+ When using AI tools to generate Rails controller code, treat the [AI Agent Quick Reference](#ai-agent-quick-reference) as the source of truth.
735
846
 
736
- - use `doc { }` or `doc("Summary") { }`; do not add the action name, and do not leave a blank line between the `doc` block and the action method
737
- - use `{ }` blocks inside `doc` as well; prefer them over `do ... end`
738
- - when a batch has 3 fields or fewer and does not contain nested hashes, prefer a single-line style, for example:
739
- - `json data: { type: String, required: true }`
740
- - `in_query(name: String, value: String)` (prefer `in_xxx(...)` batch declarations over multiple `xx` DSL lines when possible)
741
- - use `!` but not `required: true`
742
- - use `doc_dry`, `scope`, and `transform`、`px(px_key)`、`px.slice` to keep controller concise
743
- - when request parameters match model declarations, prefer `.schemas` to keep `doc` concise
847
+ The rest of this README documents all supported forms, including compatibility alternatives such as `doc(:action, ...)` and `required: true`, but generated code should follow the quick reference unless the existing application style requires otherwise.
744
848
 
745
849
  ## What Is Not Implemented Yet
746
850
 
@@ -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
@@ -57,11 +57,13 @@ module ActionSpec
57
57
 
58
58
  def add_param(location_name, field)
59
59
  location(location_name).add(field)
60
+ clear_custom_validation_cache!
60
61
  end
61
62
 
62
63
  def add_body(media_type, field)
63
64
  body.add(field)
64
65
  (@body_media_types[media_type.to_sym] ||= Location.new(media_type.to_sym)).add(field.copy)
66
+ clear_custom_validation_cache!
65
67
  end
66
68
 
67
69
  def register_scope(name, compact: nil, compact_blank: nil)
@@ -91,6 +93,7 @@ module ActionSpec
91
93
  @body_media_types = other.body_media_types
92
94
  @scope_options = other.scope_options
93
95
  @body_required = other.body_required?
96
+ clear_custom_validation_cache!
94
97
  end
95
98
 
96
99
  def copy
@@ -110,8 +113,18 @@ module ActionSpec
110
113
  end
111
114
 
112
115
  def custom_validation?
113
- [header, path, query, cookie, body].any?(&:custom_validation?)
116
+ custom_validation_locations.any?
114
117
  end
118
+
119
+ def custom_validation_locations
120
+ @custom_validation_locations ||= [header, path, query, cookie, body].select(&:custom_validation?).freeze
121
+ end
122
+
123
+ private
124
+
125
+ def clear_custom_validation_cache!
126
+ remove_instance_variable(:@custom_validation_locations) if instance_variable_defined?(:@custom_validation_locations)
127
+ end
115
128
  end
116
129
 
117
130
  class Location
@@ -126,6 +139,7 @@ module ActionSpec
126
139
 
127
140
  def add(field)
128
141
  @fields[field.name] = field
142
+ clear_custom_validation_cache!
129
143
  end
130
144
 
131
145
  def field(name)
@@ -151,7 +165,17 @@ module ActionSpec
151
165
  end
152
166
 
153
167
  def custom_validation?
154
- fields.any?(&:custom_validation?)
168
+ custom_validation_fields.any?
169
+ end
170
+
171
+ def custom_validation_fields
172
+ @custom_validation_fields ||= fields.select(&:custom_validation?).freeze
173
+ end
174
+
175
+ private
176
+
177
+ def clear_custom_validation_cache!
178
+ remove_instance_variable(:@custom_validation_fields) if instance_variable_defined?(:@custom_validation_fields)
155
179
  end
156
180
  end
157
181
 
@@ -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
 
@@ -28,7 +32,7 @@ module ActionSpec
28
32
  end
29
33
 
30
34
  def custom_validation?
31
- item.custom_validation?
35
+ @custom_validation ||= item.custom_validation?
32
36
  end
33
37
  end
34
38
  end
@@ -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?
@@ -47,15 +48,28 @@ module ActionSpec
47
48
  end
48
49
 
49
50
  def custom_validation?
50
- validate.present? || schema.custom_validation?
51
+ @custom_validation ||= validate.present? || schema.custom_validation?
52
+ end
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)
51
56
  end
52
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
@@ -38,17 +38,25 @@ module ActionSpec
38
38
  end
39
39
 
40
40
  def custom_validation?
41
- fields.any? { |_name, field| field.custom_validation? }
41
+ custom_validation_fields.any?
42
+ end
43
+
44
+ def custom_validation_fields
45
+ @custom_validation_fields ||= fields.each_value.select(&:custom_validation?).freeze
42
46
  end
43
47
 
44
48
  private
45
49
 
46
- def normalize_source(value, result:, path:)
50
+ def normalize_source(value, result:, path:, field:, context:, invalid_value:)
47
51
  return {} if value.nil?
48
52
  return value.to_unsafe_h.with_indifferent_access if value.is_a?(ActionController::Parameters)
49
53
  return value.with_indifferent_access if value.is_a?(Hash)
50
54
 
51
- result.add_error(path.join("."), :invalid)
55
+ if field
56
+ field.add_error(result, path:, type: :invalid, value: invalid_value, context:)
57
+ else
58
+ result.add_error(path.join("."), :invalid)
59
+ end
52
60
  Schema::Missing
53
61
  end
54
62
  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
@@ -17,7 +17,7 @@ module ActionSpec
17
17
  return value if value.nil?
18
18
 
19
19
  normalized = normalize(type)
20
- return value if normalized == :object
20
+ return cast_object(value) if normalized == :object
21
21
  return cast_file(value) if normalized == :file
22
22
  return cast_boolean(value) if normalized == :boolean
23
23
  return cast_integer(value) if normalized == :integer
@@ -67,6 +67,12 @@ module ActionSpec
67
67
  raise CastError, :file
68
68
  end
69
69
 
70
+ def cast_object(value)
71
+ return value if value.is_a?(Hash) || value.is_a?(ActionController::Parameters)
72
+
73
+ raise CastError, :object
74
+ end
75
+
70
76
  def cast_integer(value)
71
77
  return value if value.is_a?(Integer)
72
78
  raise CastError, :integer unless value.is_a?(String) && value.match?(/\A[+-]?\d+\z/)
@@ -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
  )
@@ -36,7 +39,7 @@ module ActionSpec
36
39
 
37
40
  def from_definition(definition)
38
41
  return Scalar.new(String) if definition.blank?
39
- return ArrayOf.new(from_definition(type: definition.first)) if definition.is_a?(Array) && definition.one?
42
+ return ArrayOf.new(from_definition(definition.first)) if definition.is_a?(Array) && definition.one?
40
43
  return ArrayOf.new(from_definition(type: nil)) if definition == []
41
44
  return Scalar.new(definition) unless definition.is_a?(Hash)
42
45
 
@@ -44,7 +47,7 @@ module ActionSpec
44
47
  if definition.key?(:type)
45
48
  type = definition[:type]
46
49
  options = definition.slice(*OPTION_KEYS)
47
- return ArrayOf.new(from_definition(type: type.first), options) if type.is_a?(Array) && type.one?
50
+ return ArrayOf.new(from_definition(type.first), options) if type.is_a?(Array) && type.one?
48
51
  return ArrayOf.new(from_definition(type: nil), options) if type == []
49
52
  if type.is_a?(Hash)
50
53
  return Scalar.new(Object, options) if type.empty?
@@ -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
@@ -25,14 +25,6 @@ module ActionSpec
25
25
 
26
26
  attr_reader :endpoint, :controller, :coerce
27
27
 
28
- BUILT_IN_GROUPS = {
29
- path: ->(request) { request.path },
30
- query: ->(request) { request.query },
31
- body: ->(request) { request.body },
32
- headers: ->(request) { request.header },
33
- cookies: ->(request) { request.cookie }
34
- }.freeze
35
-
36
28
  def merge_body!(result)
37
29
  if endpoint.request.body_required? && body_source.blank?
38
30
  result.add_error("body", :required)
@@ -101,14 +93,15 @@ module ActionSpec
101
93
  field.name
102
94
  end
103
95
 
104
- def apply_custom_validations!(result)
105
- return unless endpoint.request.custom_validation?
96
+ def apply_custom_validations!(result)
97
+ return unless endpoint.request.custom_validation?
106
98
 
107
- with_controller_px(result.px) do
108
- BUILT_IN_GROUPS.each do |location, group_reader|
109
- validate_group!(
99
+ with_controller_px(result.px) do
100
+ endpoint.request.custom_validation_locations.each do |group|
101
+ location = group.name
102
+ validate_group!(
110
103
  result,
111
- group_reader.call(endpoint.request),
104
+ group,
112
105
  values: result.px.scope.fetch(location),
113
106
  location:
114
107
  )
@@ -119,9 +112,7 @@ module ActionSpec
119
112
  def validate_group!(result, group, values:, location:)
120
113
  return unless group.custom_validation?
121
114
 
122
- group.fields.each do |field|
123
- next unless field.custom_validation?
124
-
115
+ group.custom_validation_fields.each do |field|
125
116
  key = storage_key(field, location)
126
117
  next unless values.key?(key)
127
118
 
@@ -133,7 +124,7 @@ module ActionSpec
133
124
  validate_nested_schema!(field.schema, value, result:, path:)
134
125
  return if field.validate_value(value, context: controller)
135
126
 
136
- result.add_error(path.join("."), :invalid)
127
+ field.add_error(result, path:, type: :invalid, value:, context: controller)
137
128
  end
138
129
 
139
130
  def validate_nested_schema!(schema, value, result:, path:)
@@ -144,8 +135,7 @@ module ActionSpec
144
135
  return unless value.is_a?(Hash)
145
136
 
146
137
  source = value.with_indifferent_access
147
- schema.fields.each_value do |field|
148
- next unless field.custom_validation?
138
+ schema.custom_validation_fields.each do |field|
149
139
  next unless source.key?(field.output_name)
150
140
 
151
141
  validate_field!(field, source[field.output_name], result:, path: [*path, field.name])
@@ -167,8 +157,7 @@ module ActionSpec
167
157
  return unless value.is_a?(Hash)
168
158
 
169
159
  source = value.with_indifferent_access
170
- schema.fields.each_value do |field|
171
- next unless field.custom_validation?
160
+ schema.custom_validation_fields.each do |field|
172
161
  next unless source.key?(field.output_name)
173
162
 
174
163
  validate_field!(field, source[field.output_name], result:, path: [*path, field.name])
@@ -1,3 +1,3 @@
1
1
  module ActionSpec
2
- VERSION = "1.5.0"
2
+ VERSION = "1.7.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.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhandao