action_spec 1.4.0 → 1.5.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 +124 -25
- data/lib/action_spec/doc/dsl.rb +2 -1
- data/lib/action_spec/doc/endpoint.rb +22 -1
- data/lib/action_spec/schema/active_record.rb +6 -5
- data/lib/action_spec/schema/array_of.rb +4 -0
- data/lib/action_spec/schema/base.rb +4 -0
- data/lib/action_spec/schema/field.rb +25 -3
- data/lib/action_spec/schema/object_of.rb +4 -0
- data/lib/action_spec/schema.rb +17 -4
- data/lib/action_spec/validation_result.rb +10 -0
- data/lib/action_spec/validator/runner.rb +104 -0
- 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: 9218555ca7d33709a7a5b22528f6900358e36247c123e521f2342d7a36ac02b5
|
|
4
|
+
data.tar.gz: e771af58669e707ccbcc922b6d9afa726a2b25817ab4836e01ce1154bd22344b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d03ee8f19de7c1c874302344eadf224f9aa3fba2dab228f2f4b44448a2865ba53dd97385587b6b9691308e4a987048b1eab6e5f55a1b03ef93424a643857a0c
|
|
7
|
+
data.tar.gz: 357fe37dc9a591740079c76dd3e4238a9485cb0d1e57ab020bed09bf737d1937ef22b777f8b062f78473fe497965c2df754ee3858a97f77bdd59674002cdda69
|
data/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Concise and Powerful API Documentation Solution for Rails. [中文](README_zh.md
|
|
|
16
16
|
2. [`doc_dry`](#doc_dry)
|
|
17
17
|
3. [DSL Inside `doc`](#dsl-inside-doc)
|
|
18
18
|
1. [Parameter](#parameter)
|
|
19
|
-
2. [
|
|
19
|
+
2. [Request body](#request-body)
|
|
20
20
|
3. [`openapi false`](#openapi-false)
|
|
21
21
|
4. [Scope](#scope)
|
|
22
22
|
5. [Response](#response)
|
|
@@ -29,7 +29,8 @@ Concise and Powerful API Documentation Solution for Rails. [中文](README_zh.md
|
|
|
29
29
|
4. [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
|
|
30
30
|
1. [Validation Flow](#validation-flow)
|
|
31
31
|
2. [Reading Processed Values With `px`](#reading-processed-values-with-px)
|
|
32
|
-
3. [
|
|
32
|
+
3. [`px` is Whitelist Extraction](#px-is-whitelist-extraction)
|
|
33
|
+
4. [Errors](#errors)
|
|
33
34
|
5. [Configuration And I18n](#configuration-and-i18n)
|
|
34
35
|
1. [Configuration](#configuration)
|
|
35
36
|
2. [I18n](#i18n)
|
|
@@ -44,25 +45,26 @@ class UsersController < ApplicationController
|
|
|
44
45
|
doc {
|
|
45
46
|
header :Authorization, String
|
|
46
47
|
path :account_id, Integer
|
|
47
|
-
query :locale, String, default: "zh-CN"
|
|
48
|
-
query :
|
|
48
|
+
query :locale, String, default: "zh-CN", transform: -> { it.downcase }
|
|
49
|
+
query :key_number, Integer, default: -> { it + 1 }, px: :key
|
|
49
50
|
|
|
50
51
|
form data: {
|
|
51
|
-
name!: String,
|
|
52
|
-
age: Integer,
|
|
52
|
+
name!: { type: String, transform: :strip },
|
|
53
53
|
birthday: Date,
|
|
54
54
|
admin: { type: :boolean, default: false },
|
|
55
|
-
tags: [String],
|
|
56
|
-
profile: {
|
|
57
|
-
nickname!: String
|
|
58
|
-
}
|
|
55
|
+
tags: [{ id: Integer, content!: { type: String, blank: false } }],
|
|
56
|
+
profile: { nickname!: String }
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
response 200,
|
|
59
|
+
response 200, "success", :json, data: { code: Integer, result: [Hash] }
|
|
60
|
+
errors 400, "client errors", {
|
|
61
|
+
invalid_params: { code: 1234, message: "parameters are invalid" },
|
|
62
|
+
missing_params: { code: 1235, message: "parameters are missing" }
|
|
63
|
+
}
|
|
62
64
|
}
|
|
63
65
|
def create
|
|
64
|
-
User.
|
|
65
|
-
|
|
66
|
+
User.find(px[:account_id]).update!(
|
|
67
|
+
key: px[:key], name: px[:name],
|
|
66
68
|
**px.slice(:birthday, :admin, :profile)
|
|
67
69
|
)
|
|
68
70
|
end
|
|
@@ -235,7 +237,7 @@ in_query!(
|
|
|
235
237
|
)
|
|
236
238
|
```
|
|
237
239
|
|
|
238
|
-
####
|
|
240
|
+
#### Request body
|
|
239
241
|
|
|
240
242
|
General form:
|
|
241
243
|
|
|
@@ -296,7 +298,28 @@ Then read it from `px.scope`:
|
|
|
296
298
|
px.scope[:user] # => { user_id: 1, name: "Tom" }
|
|
297
299
|
```
|
|
298
300
|
|
|
299
|
-
|
|
301
|
+
You can also trim custom scope buckets with `compact:` or `compact_blank:`:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
doc {
|
|
305
|
+
scope(:search, compact: true) {
|
|
306
|
+
query :page, Integer, transform: -> { nil }, px: :page_number
|
|
307
|
+
query :keyword, String
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
scope(:filters, compact_blank: true) {
|
|
311
|
+
query :q, String, transform: :strip
|
|
312
|
+
query :nickname, String, transform: -> { "" }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
px.scope[:search] # => { keyword: "rails" }
|
|
317
|
+
px.scope[:filters] # => { q: "ruby" }
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
These options only apply to the custom `px.scope[:name]` bucket defined by that `scope`, and use shallow hash compaction.
|
|
321
|
+
|
|
322
|
+
#### Response
|
|
300
323
|
|
|
301
324
|
```ruby
|
|
302
325
|
response 200, desc: "success"
|
|
@@ -363,21 +386,37 @@ Scalar types currently supported by validation/coercion:
|
|
|
363
386
|
- `DateTime`
|
|
364
387
|
- `Time`
|
|
365
388
|
- `File`
|
|
366
|
-
- `Object`
|
|
389
|
+
- `Object` / `Hash`
|
|
367
390
|
|
|
368
|
-
|
|
391
|
+
```ruby
|
|
392
|
+
query :page, Integer
|
|
393
|
+
form data: { file: File }
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Object and Nested Object forms:
|
|
369
397
|
|
|
370
398
|
```ruby
|
|
371
399
|
json data: {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
|
|
400
|
+
settings: Object,
|
|
401
|
+
# == settings: { type: Object }
|
|
402
|
+
# == settings: { type: Hash }
|
|
403
|
+
# == settings: { type: { } }
|
|
404
|
+
|
|
405
|
+
profile: { nickname!: String },
|
|
406
|
+
tags: [],
|
|
407
|
+
# == tags: { type: [] }
|
|
408
|
+
foo: [{ id: Integer }],
|
|
409
|
+
|
|
410
|
+
users: {
|
|
411
|
+
type: { name!: String },
|
|
412
|
+
default: { name: "Tom" },
|
|
413
|
+
transform: -> { { name: it[:name].downcase } }
|
|
414
|
+
}
|
|
378
415
|
}
|
|
379
416
|
```
|
|
380
417
|
|
|
418
|
+
When you write `xx: { type: ... }`, the type of `xx` comes from `type`. In other words, `type` is a reserved schema keyword here, not a normal nested field name.
|
|
419
|
+
|
|
381
420
|
#### Field Options
|
|
382
421
|
|
|
383
422
|
```ruby
|
|
@@ -391,12 +430,35 @@ query :title, String, blank: false # or allow_blank: false
|
|
|
391
430
|
query :nickname, String, transform: :downcase
|
|
392
431
|
query :page, Integer, transform: -> { it + 1 }, px: :page_number
|
|
393
432
|
query :request_id, String, px_key: :trace_id
|
|
433
|
+
query :end_at, Integer, validate: -> { it >= px[:start_at] }
|
|
394
434
|
```
|
|
395
435
|
|
|
396
436
|
Notes:
|
|
397
437
|
|
|
398
438
|
- `transform` accepts a `Symbol` or a `Proc` and runs **after coercion**, before the value is written into `px`
|
|
399
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
|
|
443
|
+
|
|
444
|
+
About nested object fields
|
|
445
|
+
|
|
446
|
+
Inner field options run first, and the outer object field runs last.
|
|
447
|
+
In other words, nested `transform` / `px` first participate in building the object, and after the whole object has been built, the outer field receives that final object and continues processing it.
|
|
448
|
+
Only after the whole object tree has finished resolving, coercing, and transforming does ActionSpec enter the post-`validate` phase: inner field `validate` callbacks run first, and outer object field `validate` callbacks run afterwards.
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
json data: {
|
|
452
|
+
user: {
|
|
453
|
+
type: {
|
|
454
|
+
name: { type: String, transform: :strip, px: :nickname }
|
|
455
|
+
},
|
|
456
|
+
transform: -> { { name: it[:nickname].downcase } }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
px[:user] # => { "name" => "tom" }
|
|
461
|
+
```
|
|
400
462
|
|
|
401
463
|
These options are used by OpenAPI generation:
|
|
402
464
|
|
|
@@ -434,10 +496,16 @@ You can also limit the exported fields:
|
|
|
434
496
|
User.schemas(only: %i[name phone role])
|
|
435
497
|
```
|
|
436
498
|
|
|
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:
|
|
500
|
+
|
|
501
|
+
```ruby
|
|
502
|
+
User.schemas(bang: false)
|
|
503
|
+
```
|
|
504
|
+
|
|
437
505
|
ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel when available, including:
|
|
438
506
|
|
|
439
507
|
- field type
|
|
440
|
-
- requiredness, rendered as bang keys such as `"name!"`
|
|
508
|
+
- requiredness, rendered either as bang keys such as `"name!"` or as `required: true` when `bang: false`
|
|
441
509
|
- enum values from `enum`
|
|
442
510
|
- `default`
|
|
443
511
|
- `desc` from column comments
|
|
@@ -454,6 +522,13 @@ User.schemas
|
|
|
454
522
|
# "phone!" => { type: String, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
|
|
455
523
|
# "role" => { type: String, enum: %w[admin member visitor] }
|
|
456
524
|
# }
|
|
525
|
+
|
|
526
|
+
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
|
+
# }
|
|
457
532
|
```
|
|
458
533
|
|
|
459
534
|
#### Type And Boundary Matrix
|
|
@@ -532,12 +607,13 @@ px.scope[:query]
|
|
|
532
607
|
px.scope[:body]
|
|
533
608
|
px.scope[:headers]
|
|
534
609
|
px.scope[:cookies]
|
|
610
|
+
px.scope[:the_scope_you_defined]
|
|
535
611
|
```
|
|
536
612
|
|
|
537
613
|
Notes:
|
|
538
614
|
|
|
539
615
|
- every declared field from path/query/body is also flattened into the top-level `px[:field]`
|
|
540
|
-
-
|
|
616
|
+
- `px` itself is an `ActiveSupport::HashWithIndifferentAccess`, and nested object values such as `px[:profile]` are also returned as indifferent hashes
|
|
541
617
|
- headers and cookies stay inside their own grouped buckets; for example, `px[:Authorization]` is not a top-level shortcut
|
|
542
618
|
- header keys are stored in lowercase dashed form, but reading remains compatible with original forms such as `Authorization` and `HTTP_AUTHORIZATION`, for example:
|
|
543
619
|
|
|
@@ -549,6 +625,28 @@ px.scope[:headers]["HTTP_AUTHORIZATION"]
|
|
|
549
625
|
|
|
550
626
|
- original `params` are not mutated
|
|
551
627
|
|
|
628
|
+
### `px` is Whitelist Extraction
|
|
629
|
+
|
|
630
|
+
```ruby
|
|
631
|
+
json data: {
|
|
632
|
+
profile: {
|
|
633
|
+
nickname: String
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
# request params
|
|
638
|
+
{
|
|
639
|
+
profile: {
|
|
640
|
+
nickname: "Neo",
|
|
641
|
+
role: "admin"
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
px[:profile] # => { "nickname" => "Neo" }
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Undeclared fields are filtered out, including extra keys inside nested objects.
|
|
649
|
+
|
|
552
650
|
### Errors
|
|
553
651
|
|
|
554
652
|
Validation errors are stored in `ActiveModel::Errors`.
|
|
@@ -640,6 +738,7 @@ When using AI tools to generate Rails controller code, and the change involves p
|
|
|
640
738
|
- when a batch has 3 fields or fewer and does not contain nested hashes, prefer a single-line style, for example:
|
|
641
739
|
- `json data: { type: String, required: true }`
|
|
642
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`
|
|
643
742
|
- use `doc_dry`, `scope`, and `transform`、`px(px_key)`、`px.slice` to keep controller concise
|
|
644
743
|
- when request parameters match model declarations, prefer `.schemas` to keep `doc` concise
|
|
645
744
|
|
data/lib/action_spec/doc/dsl.rb
CHANGED
|
@@ -56,7 +56,8 @@ module ActionSpec
|
|
|
56
56
|
add_body(:form, { name => options.merge(type:) })
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def scope(name, &block)
|
|
59
|
+
def scope(name, compact: nil, compact_blank: nil, &block)
|
|
60
|
+
endpoint.request.register_scope(name, compact:, compact_blank:)
|
|
60
61
|
scopes.push(name.to_sym)
|
|
61
62
|
instance_exec(&block)
|
|
62
63
|
ensure
|
|
@@ -38,7 +38,7 @@ module ActionSpec
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
class Request
|
|
41
|
-
attr_reader :header, :path, :query, :cookie, :body, :body_media_types
|
|
41
|
+
attr_reader :header, :path, :query, :cookie, :body, :body_media_types, :scope_options
|
|
42
42
|
|
|
43
43
|
def initialize
|
|
44
44
|
@header = Location.new(:header)
|
|
@@ -47,6 +47,7 @@ module ActionSpec
|
|
|
47
47
|
@cookie = Location.new(:cookie)
|
|
48
48
|
@body = Location.new(:body)
|
|
49
49
|
@body_media_types = {}
|
|
50
|
+
@scope_options = ActiveSupport::HashWithIndifferentAccess.new
|
|
50
51
|
@body_required = false
|
|
51
52
|
end
|
|
52
53
|
|
|
@@ -63,6 +64,16 @@ module ActionSpec
|
|
|
63
64
|
(@body_media_types[media_type.to_sym] ||= Location.new(media_type.to_sym)).add(field.copy)
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
def register_scope(name, compact: nil, compact_blank: nil)
|
|
68
|
+
key = name.to_sym
|
|
69
|
+
@scope_options[key] = scope_options.fetch(key, {}).merge(
|
|
70
|
+
{
|
|
71
|
+
compact:,
|
|
72
|
+
compact_blank:
|
|
73
|
+
}.compact
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
66
77
|
def require_body!
|
|
67
78
|
@body_required = true
|
|
68
79
|
end
|
|
@@ -78,6 +89,7 @@ module ActionSpec
|
|
|
78
89
|
@cookie = other.cookie
|
|
79
90
|
@body = other.body
|
|
80
91
|
@body_media_types = other.body_media_types
|
|
92
|
+
@scope_options = other.scope_options
|
|
81
93
|
@body_required = other.body_required?
|
|
82
94
|
end
|
|
83
95
|
|
|
@@ -92,9 +104,14 @@ module ActionSpec
|
|
|
92
104
|
:@body_media_types,
|
|
93
105
|
body_media_types.transform_values(&:copy)
|
|
94
106
|
)
|
|
107
|
+
request.instance_variable_set(:@scope_options, scope_options.deep_dup)
|
|
95
108
|
request.instance_variable_set(:@body_required, body_required?)
|
|
96
109
|
end
|
|
97
110
|
end
|
|
111
|
+
|
|
112
|
+
def custom_validation?
|
|
113
|
+
[header, path, query, cookie, body].any?(&:custom_validation?)
|
|
114
|
+
end
|
|
98
115
|
end
|
|
99
116
|
|
|
100
117
|
class Location
|
|
@@ -132,6 +149,10 @@ module ActionSpec
|
|
|
132
149
|
fields.each { |field| location.add(field.copy) }
|
|
133
150
|
end
|
|
134
151
|
end
|
|
152
|
+
|
|
153
|
+
def custom_validation?
|
|
154
|
+
fields.any?(&:custom_validation?)
|
|
155
|
+
end
|
|
135
156
|
end
|
|
136
157
|
|
|
137
158
|
class Response
|
|
@@ -6,12 +6,12 @@ module ActionSpec
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
class_methods do
|
|
9
|
-
def schemas(only: nil)
|
|
9
|
+
def schemas(only: nil, bang: true)
|
|
10
10
|
names = selected_column_names(only)
|
|
11
11
|
@action_spec_validator_index = build_validator_index
|
|
12
12
|
|
|
13
13
|
names.each_with_object(ActiveSupport::OrderedHash.new) do |name, hash|
|
|
14
|
-
hash[output_name(name)] = schema_definition_for(name)
|
|
14
|
+
hash[output_name(name, bang:)] = schema_definition_for(name, bang:)
|
|
15
15
|
end
|
|
16
16
|
ensure
|
|
17
17
|
remove_instance_variable(:@action_spec_validator_index) if instance_variable_defined?(:@action_spec_validator_index)
|
|
@@ -25,12 +25,13 @@ module ActionSpec
|
|
|
25
25
|
column_names.select { |name| selected.include?(name) }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def output_name(name)
|
|
29
|
-
required_attribute?(name) ? "#{name}!" : name
|
|
28
|
+
def output_name(name, bang:)
|
|
29
|
+
bang && required_attribute?(name) ? "#{name}!" : name
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def schema_definition_for(name)
|
|
32
|
+
def schema_definition_for(name, bang:)
|
|
33
33
|
definition = { type: schema_type_for(name) }
|
|
34
|
+
definition[:required] = true if required_attribute?(name) && !bang
|
|
34
35
|
definition[:default] = column_default_for(name) unless column_default_for(name).nil?
|
|
35
36
|
definition[:desc] = column_comment_for(name) if column_comment_for(name).present?
|
|
36
37
|
definition[:enum] = resolved_enum_for(name) if resolved_enum_for(name).present?
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
module Schema
|
|
5
5
|
class Field
|
|
6
|
-
attr_reader :name, :schema, :transform, :px_key, :scopes
|
|
6
|
+
attr_reader :name, :schema, :transform, :validate, :px_key, :scopes
|
|
7
7
|
|
|
8
|
-
def initialize(name:, required:, schema:, transform: nil, px_key: nil, scopes: [])
|
|
8
|
+
def initialize(name:, required:, schema:, transform: nil, validate: nil, px_key: nil, scopes: [])
|
|
9
9
|
@name = name.to_sym
|
|
10
10
|
@required = required
|
|
11
11
|
@schema = schema
|
|
12
12
|
@transform = transform
|
|
13
|
+
@validate = validate
|
|
13
14
|
@px_key = px_key&.to_sym
|
|
14
15
|
@scopes = Array(scopes).map(&:to_sym).freeze
|
|
15
16
|
end
|
|
@@ -36,8 +37,21 @@ module ActionSpec
|
|
|
36
37
|
end
|
|
37
38
|
end
|
|
38
39
|
|
|
40
|
+
def validate_value(value, context: nil)
|
|
41
|
+
return true if validate.nil? || value.equal?(ActionSpec::Schema::Missing)
|
|
42
|
+
|
|
43
|
+
case validate
|
|
44
|
+
when Proc then apply_validation_proc(value, context:)
|
|
45
|
+
else true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def custom_validation?
|
|
50
|
+
validate.present? || schema.custom_validation?
|
|
51
|
+
end
|
|
52
|
+
|
|
39
53
|
def copy
|
|
40
|
-
self.class.new(name:, required: required?, schema: schema.copy, transform:, px_key:, scopes:)
|
|
54
|
+
self.class.new(name:, required: required?, schema: schema.copy, transform:, validate:, px_key:, scopes:)
|
|
41
55
|
end
|
|
42
56
|
|
|
43
57
|
private
|
|
@@ -58,6 +72,14 @@ module ActionSpec
|
|
|
58
72
|
transform.call(value)
|
|
59
73
|
end
|
|
60
74
|
|
|
75
|
+
def apply_validation_proc(value, context:)
|
|
76
|
+
return context.instance_exec(&validate) if context && validate.arity.zero?
|
|
77
|
+
return context.instance_exec(value, &validate) if context && (validate.arity == 1 || validate.arity.negative?)
|
|
78
|
+
return validate.call if validate.arity.zero?
|
|
79
|
+
|
|
80
|
+
validate.call(value)
|
|
81
|
+
end
|
|
82
|
+
|
|
61
83
|
def invoke_context_transform(context, symbol, value)
|
|
62
84
|
method = context.method(symbol)
|
|
63
85
|
return context.public_send(symbol) if method.arity.zero?
|
|
@@ -37,6 +37,10 @@ module ActionSpec
|
|
|
37
37
|
self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, length:, blank:, desc: description, example:, examples:)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def custom_validation?
|
|
41
|
+
fields.any? { |_name, field| field.custom_validation? }
|
|
42
|
+
end
|
|
43
|
+
|
|
40
44
|
private
|
|
41
45
|
|
|
42
46
|
def normalize_source(value, result:, path:)
|
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]).freeze
|
|
16
|
+
FIELD_OPTION_KEYS = (OPTION_KEYS + %i[required transform px px_key validate]).freeze
|
|
17
17
|
|
|
18
18
|
class << self
|
|
19
19
|
def build(type = nil, **options)
|
|
@@ -28,6 +28,7 @@ module ActionSpec
|
|
|
28
28
|
required: required_key?(name) || required || explicit_required?(definition),
|
|
29
29
|
schema: build_field_schema(strip_field_options(definition)),
|
|
30
30
|
transform: explicit_transform(definition),
|
|
31
|
+
validate: explicit_validate(definition),
|
|
31
32
|
px_key: explicit_px_key(definition),
|
|
32
33
|
scopes:
|
|
33
34
|
)
|
|
@@ -42,9 +43,17 @@ module ActionSpec
|
|
|
42
43
|
definition = definition.deep_symbolize_keys
|
|
43
44
|
if definition.key?(:type)
|
|
44
45
|
type = definition[:type]
|
|
45
|
-
options = definition.
|
|
46
|
+
options = definition.slice(*OPTION_KEYS)
|
|
46
47
|
return ArrayOf.new(from_definition(type: type.first), options) if type.is_a?(Array) && type.one?
|
|
47
|
-
return
|
|
48
|
+
return ArrayOf.new(from_definition(type: nil), options) if type == []
|
|
49
|
+
if type.is_a?(Hash)
|
|
50
|
+
return Scalar.new(Object, options) if type.empty?
|
|
51
|
+
|
|
52
|
+
return ObjectOf.new(build_fields(type), options)
|
|
53
|
+
end
|
|
54
|
+
if [Object, Hash].include?(type) && definition.except(:type, *OPTION_KEYS).present?
|
|
55
|
+
return ObjectOf.new(build_fields(definition.except(:type, *OPTION_KEYS)), options)
|
|
56
|
+
end
|
|
48
57
|
|
|
49
58
|
return Scalar.new(type, options)
|
|
50
59
|
end
|
|
@@ -108,6 +117,10 @@ module ActionSpec
|
|
|
108
117
|
definition.is_a?(Hash) ? definition.symbolize_keys[:transform] : nil
|
|
109
118
|
end
|
|
110
119
|
|
|
120
|
+
def explicit_validate(definition)
|
|
121
|
+
definition.is_a?(Hash) ? definition.symbolize_keys[:validate] : nil
|
|
122
|
+
end
|
|
123
|
+
|
|
111
124
|
def explicit_px_key(definition)
|
|
112
125
|
return unless definition.is_a?(Hash)
|
|
113
126
|
|
|
@@ -124,7 +137,7 @@ module ActionSpec
|
|
|
124
137
|
def strip_field_options(definition)
|
|
125
138
|
return definition unless definition.is_a?(Hash)
|
|
126
139
|
|
|
127
|
-
definition.symbolize_keys.except(:required, :transform, :px, :px_key)
|
|
140
|
+
definition.symbolize_keys.except(:required, :transform, :px, :px_key, :validate)
|
|
128
141
|
end
|
|
129
142
|
end
|
|
130
143
|
end
|
|
@@ -30,6 +30,16 @@ module ActionSpec
|
|
|
30
30
|
Array(scopes).each { |scope_name| scope_bucket(scope_name)[key] = value }
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
def apply_scope_options!(options_by_scope)
|
|
34
|
+
options_by_scope.each do |scope_name, options|
|
|
35
|
+
bucket = px.scope[scope_name]
|
|
36
|
+
next unless bucket
|
|
37
|
+
|
|
38
|
+
bucket.compact! if options[:compact]
|
|
39
|
+
bucket.delete_if { |_key, value| value.blank? } if options[:compact_blank]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
33
43
|
def add_error(attribute, type, **options)
|
|
34
44
|
if (message = ActionSpec.config.message_for(attribute, type, options))
|
|
35
45
|
errors.add(attribute, message)
|
|
@@ -16,6 +16,8 @@ module ActionSpec
|
|
|
16
16
|
merge_body!(result)
|
|
17
17
|
merge_group!(result, endpoint.request.header, source: header_source, location: :headers)
|
|
18
18
|
merge_group!(result, endpoint.request.cookie, source: cookie_source, location: :cookies)
|
|
19
|
+
result.apply_scope_options!(endpoint.request.scope_options)
|
|
20
|
+
apply_custom_validations!(result)
|
|
19
21
|
result
|
|
20
22
|
end
|
|
21
23
|
|
|
@@ -23,6 +25,14 @@ module ActionSpec
|
|
|
23
25
|
|
|
24
26
|
attr_reader :endpoint, :controller, :coerce
|
|
25
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
|
+
|
|
26
36
|
def merge_body!(result)
|
|
27
37
|
if endpoint.request.body_required? && body_source.blank?
|
|
28
38
|
result.add_error("body", :required)
|
|
@@ -90,6 +100,100 @@ module ActionSpec
|
|
|
90
100
|
|
|
91
101
|
field.name
|
|
92
102
|
end
|
|
103
|
+
|
|
104
|
+
def apply_custom_validations!(result)
|
|
105
|
+
return unless endpoint.request.custom_validation?
|
|
106
|
+
|
|
107
|
+
with_controller_px(result.px) do
|
|
108
|
+
BUILT_IN_GROUPS.each do |location, group_reader|
|
|
109
|
+
validate_group!(
|
|
110
|
+
result,
|
|
111
|
+
group_reader.call(endpoint.request),
|
|
112
|
+
values: result.px.scope.fetch(location),
|
|
113
|
+
location:
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_group!(result, group, values:, location:)
|
|
120
|
+
return unless group.custom_validation?
|
|
121
|
+
|
|
122
|
+
group.fields.each do |field|
|
|
123
|
+
next unless field.custom_validation?
|
|
124
|
+
|
|
125
|
+
key = storage_key(field, location)
|
|
126
|
+
next unless values.key?(key)
|
|
127
|
+
|
|
128
|
+
validate_field!(field, values[key], result:, path: [field.name])
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate_field!(field, value, result:, path:)
|
|
133
|
+
validate_nested_schema!(field.schema, value, result:, path:)
|
|
134
|
+
return if field.validate_value(value, context: controller)
|
|
135
|
+
|
|
136
|
+
result.add_error(path.join("."), :invalid)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate_nested_schema!(schema, value, result:, path:)
|
|
140
|
+
return unless schema.custom_validation?
|
|
141
|
+
|
|
142
|
+
case schema
|
|
143
|
+
when ActionSpec::Schema::ObjectOf
|
|
144
|
+
return unless value.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
source = value.with_indifferent_access
|
|
147
|
+
schema.fields.each_value do |field|
|
|
148
|
+
next unless field.custom_validation?
|
|
149
|
+
next unless source.key?(field.output_name)
|
|
150
|
+
|
|
151
|
+
validate_field!(field, source[field.output_name], result:, path: [*path, field.name])
|
|
152
|
+
end
|
|
153
|
+
when ActionSpec::Schema::ArrayOf
|
|
154
|
+
return unless value.is_a?(Array)
|
|
155
|
+
|
|
156
|
+
value.each_with_index do |entry, index|
|
|
157
|
+
validate_array_item!(schema.item, entry, result:, path: [*path, index])
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_array_item!(schema, value, result:, path:)
|
|
163
|
+
return unless schema.custom_validation?
|
|
164
|
+
|
|
165
|
+
case schema
|
|
166
|
+
when ActionSpec::Schema::ObjectOf
|
|
167
|
+
return unless value.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
source = value.with_indifferent_access
|
|
170
|
+
schema.fields.each_value do |field|
|
|
171
|
+
next unless field.custom_validation?
|
|
172
|
+
next unless source.key?(field.output_name)
|
|
173
|
+
|
|
174
|
+
validate_field!(field, source[field.output_name], result:, path: [*path, field.name])
|
|
175
|
+
end
|
|
176
|
+
when ActionSpec::Schema::ArrayOf
|
|
177
|
+
return unless value.is_a?(Array)
|
|
178
|
+
|
|
179
|
+
value.each_with_index do |entry, index|
|
|
180
|
+
validate_array_item!(schema.item, entry, result:, path: [*path, index])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def with_controller_px(px)
|
|
186
|
+
previous_defined = controller.instance_variable_defined?(:@px)
|
|
187
|
+
previous = controller.instance_variable_get(:@px)
|
|
188
|
+
controller.instance_variable_set(:@px, px)
|
|
189
|
+
yield
|
|
190
|
+
ensure
|
|
191
|
+
if previous_defined
|
|
192
|
+
controller.instance_variable_set(:@px, previous)
|
|
193
|
+
else
|
|
194
|
+
controller.remove_instance_variable(:@px) if controller.instance_variable_defined?(:@px)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
93
197
|
end
|
|
94
198
|
end
|
|
95
199
|
end
|
data/lib/action_spec/version.rb
CHANGED