action_spec 1.6.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 +37 -21
- data/lib/action_spec/doc/endpoint.rb +26 -2
- data/lib/action_spec/schema/array_of.rb +1 -1
- data/lib/action_spec/schema/field.rb +1 -1
- data/lib/action_spec/schema/object_of.rb +5 -1
- data/lib/action_spec/schema/type_caster.rb +7 -1
- data/lib/action_spec/schema.rb +2 -2
- data/lib/action_spec/validator/runner.rb +10 -21
- 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") {
|
|
@@ -211,7 +224,7 @@ end
|
|
|
211
224
|
|
|
212
225
|
With `required_allow_blank = false`, required fields reject blank strings unless that field explicitly sets `blank:` or `allow_blank:`.
|
|
213
226
|
|
|
214
|
-
|
|
227
|
+
Compatibility alternative: if you prefer not to use bang methods, you can also write `required: true`:
|
|
215
228
|
|
|
216
229
|
```ruby
|
|
217
230
|
query :page, Integer, required: true
|
|
@@ -285,7 +298,16 @@ Notes:
|
|
|
285
298
|
You can also opt an action out of OpenAPI generation from either `doc` or `doc_dry`:
|
|
286
299
|
|
|
287
300
|
```ruby
|
|
288
|
-
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
|
+
}
|
|
289
311
|
```
|
|
290
312
|
|
|
291
313
|
#### Scope
|
|
@@ -475,6 +497,7 @@ query :birthday, Date, error: "birthday error"
|
|
|
475
497
|
- `transform`
|
|
476
498
|
- Applies one more custom transformation to the **already-coerced value**.
|
|
477
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.
|
|
478
501
|
- `px` / `px_key`
|
|
479
502
|
- Customize the key name used when the parameter is written into `px`.
|
|
480
503
|
- `validate`
|
|
@@ -620,7 +643,7 @@ User.schemas(bang: false)
|
|
|
620
643
|
| `DateTime` | `"2025-10-17T12:30:00Z"` | Rejects invalid datetimes such as `"2025-10-17 25:00:00"` |
|
|
621
644
|
| `Time` | `"2025-10-17T12:30:00Z"` | Follows `ActiveModel::Type::Time`, so the date part becomes `2000-01-01` |
|
|
622
645
|
| `File` | `ActionDispatch::Http::UploadedFile`, `Tempfile`, file-like IO objects | Keeps the object as-is and does not read file contents into memory |
|
|
623
|
-
| `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 |
|
|
624
647
|
| `[Type]` | arrays such as `%w[1 2 3]` for `[Integer]` | Rejects non-array values, and reports item errors like `tags.1` |
|
|
625
648
|
| nested object | `{ profile: { nickname: "neo" } }` | Rejects non-hash values, and reports nested paths like `profile.nickname` |
|
|
626
649
|
|
|
@@ -810,25 +833,18 @@ If you want to override one specific field directly in the DSL, use `error` or `
|
|
|
810
833
|
```ruby
|
|
811
834
|
doc {
|
|
812
835
|
query! :page, Integer, error: "choose a page first"
|
|
813
|
-
query :role, String, validate: -> { false },
|
|
836
|
+
query :role, String, validate: -> { false }, error: -> { "is not allowed for #{current_user}" }
|
|
814
837
|
json data: {
|
|
815
|
-
birthday!: { type: Date,
|
|
838
|
+
birthday!: { type: Date, error: ->(error, value) { "#{error}: #{value.inspect}" } }
|
|
816
839
|
}
|
|
817
840
|
}
|
|
818
841
|
```
|
|
819
842
|
|
|
820
843
|
## AI Generation Style Guide
|
|
821
844
|
|
|
822
|
-
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.
|
|
823
846
|
|
|
824
|
-
|
|
825
|
-
- use `{ }` blocks inside `doc` as well; prefer them over `do ... end`
|
|
826
|
-
- when a batch has 3 fields or fewer and does not contain nested hashes, prefer a single-line style, for example:
|
|
827
|
-
- `json data: { type: String, required: true }`
|
|
828
|
-
- `in_query(name: String, value: String)` (prefer `in_xxx(...)` batch declarations over multiple `xx` DSL lines when possible)
|
|
829
|
-
- use `!` but not `required: true`
|
|
830
|
-
- use `doc_dry`, `scope`, and `transform`、`px(px_key)`、`px.slice` to keep controller concise
|
|
831
|
-
- 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.
|
|
832
848
|
|
|
833
849
|
## What Is Not Implemented Yet
|
|
834
850
|
|
|
@@ -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
|
|
|
@@ -38,7 +38,11 @@ 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
|
|
@@ -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
|
@@ -39,7 +39,7 @@ module ActionSpec
|
|
|
39
39
|
|
|
40
40
|
def from_definition(definition)
|
|
41
41
|
return Scalar.new(String) if definition.blank?
|
|
42
|
-
return ArrayOf.new(from_definition(
|
|
42
|
+
return ArrayOf.new(from_definition(definition.first)) if definition.is_a?(Array) && definition.one?
|
|
43
43
|
return ArrayOf.new(from_definition(type: nil)) if definition == []
|
|
44
44
|
return Scalar.new(definition) unless definition.is_a?(Hash)
|
|
45
45
|
|
|
@@ -47,7 +47,7 @@ module ActionSpec
|
|
|
47
47
|
if definition.key?(:type)
|
|
48
48
|
type = definition[:type]
|
|
49
49
|
options = definition.slice(*OPTION_KEYS)
|
|
50
|
-
return ArrayOf.new(from_definition(type
|
|
50
|
+
return ArrayOf.new(from_definition(type.first), options) if type.is_a?(Array) && type.one?
|
|
51
51
|
return ArrayOf.new(from_definition(type: nil), options) if type == []
|
|
52
52
|
if type.is_a?(Hash)
|
|
53
53
|
return Scalar.new(Object, options) if type.empty?
|
|
@@ -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
|
|
|
@@ -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