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 +4 -4
- data/README.md +146 -42
- data/lib/action_spec/configuration.rb +3 -1
- data/lib/action_spec/doc/endpoint.rb +26 -2
- data/lib/action_spec/schema/active_record.rb +159 -24
- data/lib/action_spec/schema/array_of.rb +9 -5
- data/lib/action_spec/schema/base.rb +20 -14
- data/lib/action_spec/schema/field.rb +28 -4
- data/lib/action_spec/schema/object_of.rb +14 -6
- data/lib/action_spec/schema/resolver.rb +17 -10
- data/lib/action_spec/schema/scalar.rb +11 -3
- data/lib/action_spec/schema/type_caster.rb +7 -1
- data/lib/action_spec/schema.rb +34 -8
- data/lib/action_spec/validation_result.rb +3 -1
- data/lib/action_spec/validator/runner.rb +11 -22
- 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: 06b66175d9094e2a53ff6c65cdfd88ad24f91724535ff8e0152341e079d18d0b
|
|
4
|
+
data.tar.gz: 562f73d96c1f1d57a72ec04e866a14a14fbcf0e12df757ed3b9a334a977450cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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. [
|
|
14
|
-
2. [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
6. [Configuration And I18n](#configuration-and-i18n)
|
|
35
36
|
1. [Configuration](#configuration)
|
|
36
37
|
2. [I18n](#i18n)
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`".
|
|
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
|
|
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 :
|
|
433
|
-
query :
|
|
466
|
+
query :end_at, Integer, validate: -> { current_user && it >= px[:start_at] }
|
|
467
|
+
query :birthday, Date, error: "birthday error"
|
|
434
468
|
```
|
|
435
469
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
-
|
|
439
|
-
- `
|
|
440
|
-
-
|
|
441
|
-
-
|
|
442
|
-
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
#
|
|
522
|
-
#
|
|
523
|
-
#
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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/)
|
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
|
)
|
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
105
|
-
|
|
96
|
+
def apply_custom_validations!(result)
|
|
97
|
+
return unless endpoint.request.custom_validation?
|
|
106
98
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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])
|
data/lib/action_spec/version.rb
CHANGED