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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4483d08f3dbf8917159d539ee9b064366551409ae646714def0b89595484428
4
- data.tar.gz: 065af0d1f835124179afef0a756515b6184598cd35ef217a96615f18b2098cbe
3
+ metadata.gz: 06b66175d9094e2a53ff6c65cdfd88ad24f91724535ff8e0152341e079d18d0b
4
+ data.tar.gz: 562f73d96c1f1d57a72ec04e866a14a14fbcf0e12df757ed3b9a334a977450cd
5
5
  SHA512:
6
- metadata.gz: 71ae3fd218312cb0e788f7a5affad8e31da416546e319852509d260490fe261009be24fc51cedfc5948c8c7e9cee04285b50019e613316f3a96605d7cee98b1f
7
- data.tar.gz: 7aaee031ed042a3631c952789ccf4ca37db7f7a7f53fcb6a45f1a0e8c4badad005cd4e8774fad58d467c8b2f49d543f4d499407b98cad4dde8fe4e6dbe7cc197
6
+ metadata.gz: 581a39acd1d534f2c81647d8f9877423bcb53e6ea2ef0deab20c071c88a9fd160351721591e8af4007f2d9543c8375089017247a2a48a1314cb76421d40f08c5
7
+ data.tar.gz: 8db69ee15a9ac6cf205e8ce40563456f498cb6a2b31ee87104ce1fe524fc33efe46caf11fb151d114380c599ecd3060eaa910185d1915ed6e42e9959186a8af6
data/README.md CHANGED
@@ -10,8 +10,9 @@ Concise and Powerful API Documentation Solution for Rails. [中文](README_zh.md
10
10
 
11
11
  ## Table Of Contents
12
12
 
13
- 1. [OpenAPI Generation](#openapi-generation)
14
- 2. [Doc DSL](#doc-dsl)
13
+ 1. [AI Agent Quick Reference](#ai-agent-quick-reference)
14
+ 2. [OpenAPI Generation](#openapi-generation)
15
+ 3. [Doc DSL](#doc-dsl)
15
16
  1. [`doc`](#doc)
16
17
  2. [`doc_dry`](#doc_dry)
17
18
  3. [DSL Inside `doc`](#dsl-inside-doc)
@@ -20,21 +21,21 @@ Concise and Powerful API Documentation Solution for Rails. [中文](README_zh.md
20
21
  3. [`openapi false`](#openapi-false)
21
22
  4. [Scope](#scope)
22
23
  5. [Response](#response)
23
- 3. [Schemas](#schemas)
24
+ 4. [Schemas](#schemas)
24
25
  1. [Declare A Required Field](#declare-a-required-field)
25
26
  2. [Field Types](#field-types)
26
27
  3. [Field Options](#field-options)
27
28
  4. [Schemas From ActiveRecord](#schemas-from-activerecord)
28
29
  5. [Type And Boundary Matrix](#type-and-boundary-matrix)
29
- 4. [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
30
+ 5. [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
30
31
  1. [Validation Flow](#validation-flow)
31
32
  2. [Reading Processed Values With `px`](#reading-processed-values-with-px)
32
33
  3. [`px` is Whitelist Extraction](#px-is-whitelist-extraction)
33
34
  4. [Errors](#errors)
34
- 5. [Configuration And I18n](#configuration-and-i18n)
35
+ 6. [Configuration And I18n](#configuration-and-i18n)
35
36
  1. [Configuration](#configuration)
36
37
  2. [I18n](#i18n)
37
- 6. [AI Generation Style Guide](#ai-generation-style-guide)
38
+ 7. [AI Generation Style Guide](#ai-generation-style-guide)
38
39
 
39
40
  ## Example
40
41
 
@@ -71,6 +72,18 @@ class UsersController < ApplicationController
71
72
  end
72
73
  ```
73
74
 
75
+ ## AI Agent Quick Reference
76
+
77
+ When generating Rails controller code with ActionSpec, use these as the canonical choices:
78
+
79
+ - put `doc { }` or `doc("Summary") { }` immediately above the action method and let ActionSpec infer the action name
80
+ - use `{ }` blocks inside `doc`
81
+ - prefer bang required syntax, such as `query! :id, Integer` and `name!: String`; keep `required: true` for compatibility or generated schemas
82
+ - fold simple nested hash fields, `data: { }`, or `in_xxx(...)` declarations into one line when they have 2 fields or fewer and no complex nesting, such as `json data: { name: String, age: Integer }` or `in_query(name: String, value: String)`
83
+ - declare body fields as `json data: { name!: String }` or `form data: { avatar!: File }`
84
+ - use `doc_dry`, `scope`, `transform`, `px` / `px_key`, `.schemas`, and `px.slice` to keep controller actions small
85
+ - rely on ActionSpec for parameter validation, type coercion, defaults, and similar contracts instead of rewriting the same parameter handling by hand
86
+
74
87
  ## Installation
75
88
 
76
89
  ```ruby
@@ -142,7 +155,7 @@ def create
142
155
  end
143
156
  ```
144
157
 
145
- You can also bind it explicitly when you want the action name declared in place:
158
+ Escape hatch: bind the action explicitly when the inferred next method is not the intended action:
146
159
 
147
160
  ```ruby
148
161
  doc(:create, "Create user") {
@@ -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
- If you prefer not to use bang methods, you can also write `required: true`:
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`, arbitrary Ruby objects | Passed through for scalar `Object`; nested hashes use object schema resolution |
646
+ | `Object` | `Hash`, `ActionController::Parameters` | Scalar `Object` behaves like `Hash` and rejects non-hash values; nested hashes use object schema resolution |
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 }, error_message: -> { "is not allowed for #{current_user}" }
836
+ query :role, String, validate: -> { false }, error: -> { "is not allowed for #{current_user}" }
814
837
  json data: {
815
- birthday!: { type: Date, error_message: ->(error, value) { "#{error}: #{value.inspect}" } }
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, and the change involves parameter validation, type coercion, default values, or similar parameter contracts, these conventions work well with ActionSpec:
845
+ When using AI tools to generate Rails controller code, treat the [AI Agent Quick Reference](#ai-agent-quick-reference) as the source of truth.
823
846
 
824
- - use `doc { }` or `doc("Summary") { }`; do not add the action name, and do not leave a blank line between the `doc` block and the action method
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
- [header, path, query, cookie, body].any?(&:custom_validation?)
116
+ custom_validation_locations.any?
114
117
  end
118
+
119
+ def custom_validation_locations
120
+ @custom_validation_locations ||= [header, path, query, cookie, body].select(&:custom_validation?).freeze
121
+ end
122
+
123
+ private
124
+
125
+ def clear_custom_validation_cache!
126
+ remove_instance_variable(:@custom_validation_locations) if instance_variable_defined?(:@custom_validation_locations)
127
+ end
115
128
  end
116
129
 
117
130
  class Location
@@ -126,6 +139,7 @@ module ActionSpec
126
139
 
127
140
  def add(field)
128
141
  @fields[field.name] = field
142
+ clear_custom_validation_cache!
129
143
  end
130
144
 
131
145
  def field(name)
@@ -151,7 +165,17 @@ module ActionSpec
151
165
  end
152
166
 
153
167
  def custom_validation?
154
- fields.any?(&:custom_validation?)
168
+ custom_validation_fields.any?
169
+ end
170
+
171
+ def custom_validation_fields
172
+ @custom_validation_fields ||= fields.select(&:custom_validation?).freeze
173
+ end
174
+
175
+ private
176
+
177
+ def clear_custom_validation_cache!
178
+ remove_instance_variable(:@custom_validation_fields) if instance_variable_defined?(:@custom_validation_fields)
155
179
  end
156
180
  end
157
181
 
@@ -32,7 +32,7 @@ module ActionSpec
32
32
  end
33
33
 
34
34
  def custom_validation?
35
- item.custom_validation?
35
+ @custom_validation ||= item.custom_validation?
36
36
  end
37
37
  end
38
38
  end
@@ -48,7 +48,7 @@ module ActionSpec
48
48
  end
49
49
 
50
50
  def custom_validation?
51
- validate.present? || schema.custom_validation?
51
+ @custom_validation ||= validate.present? || schema.custom_validation?
52
52
  end
53
53
 
54
54
  def add_error(result, path:, type:, value:, context: nil, **options)
@@ -38,7 +38,11 @@ module ActionSpec
38
38
  end
39
39
 
40
40
  def custom_validation?
41
- fields.any? { |_name, field| field.custom_validation? }
41
+ custom_validation_fields.any?
42
+ end
43
+
44
+ def custom_validation_fields
45
+ @custom_validation_fields ||= fields.each_value.select(&:custom_validation?).freeze
42
46
  end
43
47
 
44
48
  private
@@ -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/)
@@ -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(type: definition.first)) if definition.is_a?(Array) && definition.one?
42
+ return ArrayOf.new(from_definition(definition.first)) if definition.is_a?(Array) && definition.one?
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: type.first), options) if type.is_a?(Array) && type.one?
50
+ return ArrayOf.new(from_definition(type.first), options) if type.is_a?(Array) && type.one?
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
- def apply_custom_validations!(result)
105
- return unless endpoint.request.custom_validation?
96
+ def apply_custom_validations!(result)
97
+ return unless endpoint.request.custom_validation?
106
98
 
107
- with_controller_px(result.px) do
108
- BUILT_IN_GROUPS.each do |location, group_reader|
109
- validate_group!(
99
+ with_controller_px(result.px) do
100
+ endpoint.request.custom_validation_locations.each do |group|
101
+ location = group.name
102
+ validate_group!(
110
103
  result,
111
- group_reader.call(endpoint.request),
104
+ group,
112
105
  values: result.px.scope.fetch(location),
113
106
  location:
114
107
  )
@@ -119,9 +112,7 @@ module ActionSpec
119
112
  def validate_group!(result, group, values:, location:)
120
113
  return unless group.custom_validation?
121
114
 
122
- group.fields.each do |field|
123
- next unless field.custom_validation?
124
-
115
+ group.custom_validation_fields.each do |field|
125
116
  key = storage_key(field, location)
126
117
  next unless values.key?(key)
127
118
 
@@ -144,8 +135,7 @@ module ActionSpec
144
135
  return unless value.is_a?(Hash)
145
136
 
146
137
  source = value.with_indifferent_access
147
- schema.fields.each_value do |field|
148
- next unless field.custom_validation?
138
+ schema.custom_validation_fields.each do |field|
149
139
  next unless source.key?(field.output_name)
150
140
 
151
141
  validate_field!(field, source[field.output_name], result:, path: [*path, field.name])
@@ -167,8 +157,7 @@ module ActionSpec
167
157
  return unless value.is_a?(Hash)
168
158
 
169
159
  source = value.with_indifferent_access
170
- schema.fields.each_value do |field|
171
- next unless field.custom_validation?
160
+ schema.custom_validation_fields.each do |field|
172
161
  next unless source.key?(field.output_name)
173
162
 
174
163
  validate_field!(field, source[field.output_name], result:, path: [*path, field.name])
@@ -1,3 +1,3 @@
1
1
  module ActionSpec
2
- VERSION = "1.6.0"
2
+ VERSION = "1.7.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: action_spec
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhandao