action_spec 1.1.0 → 1.3.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: 0adf599aac952bde5c1969385d71ea5a023fe20814abd835a8ab55dca32ca319
4
- data.tar.gz: 6761792a92ae44fd93b7fad87d350d952494ff6c3b7bc17023ae5fd7020d373f
3
+ metadata.gz: 02c55ca1816d1b61ad17a4a735c1704bb887cc4f67d842a7ed351641c47e93d8
4
+ data.tar.gz: a148b5f034a92b4f6648c3deb5e29aa997588e6162cd761e3586e9840d6b0fa2
5
5
  SHA512:
6
- metadata.gz: 5490efab1cd187ab21a97c10401dc3471c81dccc161e40955b5e53cb2f9b5cacf375cb55cbed382bc19f47e7bd0b9f69aa870c4ca0bf9f78663b6f89e1973d74
7
- data.tar.gz: ee1d9eb0eaacbbc8df53b5db72568006dde268ecf400f0de4f60087ee60511a51fde327716c5a23612398dd01a0f6e37665123569f28e9e95bba32bd74a03107
6
+ metadata.gz: 1eb2aa363f52d5800497364315168186b3fb04fc0fc1541ebe2075a73284585a5302b68f48183f5163451607f53bddc42474a988a6549dcb4daf7849bd2bf9aa
7
+ data.tar.gz: 985e5bd7551b4f6bd9919209137c90cae86a3a2720113dacb01e421849fca4e16a602550fb833a920d703f997c97a2b8b87c49ceda5ce4ecefa9bfea1a641988
data/README.md CHANGED
@@ -6,7 +6,7 @@ Concise and Powerful API Documentation Solution for Rails.
6
6
 
7
7
  - OpenAPI version: `v3.2.0`
8
8
  - Requires: Ruby 3.1+ and Rails 7.0+
9
- - Note: this project was implemented with Codex in about 2 hours, has not yet been manually reviewed, and has not been validated in production. It does, however, come with fairly detailed RSpec tests generated with Codex.
9
+ - Note: this project was implemented with Codex in about 3 hours, has not yet been manually reviewed, and has not been validated in production. It does, however, come with fairly detailed RSpec tests generated with Codex.
10
10
 
11
11
  ## Table Of Contents
12
12
 
@@ -14,6 +14,8 @@ Concise and Powerful API Documentation Solution for Rails.
14
14
  - [Doc DSL](#doc-dsl)
15
15
  - [`doc`](#doc)
16
16
  - [`doc_dry`](#doc_dry)
17
+ - [`openapi false`](#openapi-false)
18
+ - [`tag`](#tag)
17
19
  - [DSL Reference](#dsl-reference)
18
20
  - [Schemas](#schemas)
19
21
  - [Declare A Required Field](#declare-a-required-field)
@@ -23,12 +25,12 @@ Concise and Powerful API Documentation Solution for Rails.
23
25
  - [Type And Boundary Matrix](#type-and-boundary-matrix)
24
26
  - [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
25
27
  - [Validation Flow](#validation-flow)
26
- - [Reading Validated Values With `px`](#reading-validated-values-with-px)
28
+ - [Reading Processed Values With `px`](#reading-processed-values-with-px)
27
29
  - [Errors](#errors)
28
- - [Default Rescue Behavior](#default-rescue-behavior)
29
30
  - [Configuration And I18n](#configuration-and-i18n)
30
31
  - [Configuration](#configuration)
31
32
  - [I18n](#i18n)
33
+ - [AI Generation Style Guide](#ai-generation-style-guide)
32
34
 
33
35
  ## Example
34
36
 
@@ -106,6 +108,7 @@ bin/rails action_spec:gen \
106
108
  Notes:
107
109
 
108
110
  - only routed controller actions with a matching `doc` declaration are included
111
+ - endpoints with `openapi false` are skipped even when routed
109
112
  - Rails paths such as `/users/:id(.:format)` are rendered as `/users/{id}`
110
113
  - parameters, request bodies, and response descriptions are generated from the current DSL support
111
114
  - if config and environment variables do not provide `TITLE` or `VERSION`, ActionSpec falls back to application-derived defaults
@@ -150,19 +153,43 @@ end
150
153
 
151
154
  ```ruby
152
155
  class ApplicationController < ActionController::API
153
- doc_dry %i[show update destroy] do
156
+ doc_dry(%i[show update destroy]) {
154
157
  path! :id, Integer
155
- end
158
+ }
156
159
 
157
- doc_dry :index do
160
+ doc_dry(:index) {
158
161
  query :page, Integer, default: 1
159
162
  query :per, Integer, default: 20
160
- end
163
+ }
161
164
  end
162
165
  ```
163
166
 
164
167
  All matching dry blocks are applied before the action-specific `doc`.
165
168
 
169
+ ### `openapi false`
170
+
171
+ You can also opt an action out of OpenAPI generation from either `doc` or `doc_dry`:
172
+
173
+ ```ruby
174
+ doc {
175
+ openapi false
176
+ }
177
+ ```
178
+
179
+ ### `tag`
180
+
181
+ OpenAPI tags can also be set at either level:
182
+
183
+ ```ruby
184
+ doc_dry(:index, tag: "backoffice")
185
+
186
+ doc("List users", tag: "members") {
187
+ query :status, String
188
+ }
189
+ ```
190
+
191
+ Generated OpenAPI operations also include an `operationId`, built from the final tag plus the action name, for example `members_index` or `users_create`.
192
+
166
193
  ### DSL Reference
167
194
 
168
195
  #### Parameter
@@ -238,6 +265,33 @@ data :file, File
238
265
 
239
266
  For `body/body!`, `json/json!`, and `form/form!`, the bang form is currently kept for DSL compatibility. At runtime they all contribute to the same body contract, and root-body requiredness is not yet enforced as a separate rule.
240
267
 
268
+ #### OpenAPI
269
+
270
+ ```ruby
271
+ openapi false
272
+ ```
273
+
274
+ Use this when an action should stay out of the generated OpenAPI document. It also works inside `doc_dry`.
275
+
276
+ #### Scope
277
+
278
+ Use `scope` when you want a grouped view that spans multiple request locations:
279
+
280
+ ```ruby
281
+ doc {
282
+ scope(:user) {
283
+ query :user_id, Integer
284
+ form data: { name: String }
285
+ }
286
+ }
287
+ ```
288
+
289
+ Then read it from `px.scope`:
290
+
291
+ ```ruby
292
+ px.scope[:user] # => { user_id: 1, name: "Tom" }
293
+ ```
294
+
241
295
  #### Response
242
296
 
243
297
  ```ruby
@@ -339,7 +393,7 @@ end
339
393
 
340
394
  `User.schemas` returns a hash that can be passed directly into `form data:`, `json data:`, or `body`.
341
395
 
342
- By default it includes all model fields:
396
+ By default, it includes all model fields:
343
397
 
344
398
  ```ruby
345
399
  User.schemas
@@ -409,6 +463,8 @@ Example:
409
463
  - DSL says `query :page, Integer`
410
464
  - result: `px[:page] == "2"`
411
465
 
466
+ You can safely put this hook on a base controller. If the current action has no matching `doc`, ActionSpec skips validation and returns an empty `px`.
467
+
412
468
  #### `validate_and_coerce_params!`
413
469
 
414
470
  Validates and coerces values before exposing them on `px`.
@@ -423,36 +479,42 @@ Example:
423
479
  - DSL says `query :page, Integer`
424
480
  - result: `px[:page] == 2`
425
481
 
426
- ### Reading Validated Values With `px`
482
+ This hook also skips actions without a matching `doc`, so it is safe to declare on a shared base controller.
427
483
 
428
- `px` is a hash.
484
+ ### Reading Processed Values With `px`
485
+
486
+ `px` stores the processed values produced by ActionSpec. With `validate_params!` they stay raw; with `validate_and_coerce_params!` they are coerced values.
487
+ Because `px` is still a hash, you can also use helpers such as `px.slice(...)` to simplify parameter access code.
429
488
 
430
489
  ```ruby
431
490
  px[:id]
432
491
  px[:page]
433
492
  px[:profile][:nickname]
434
493
  px.to_h
494
+ px.scope[:user]
435
495
  ```
436
496
 
437
- It also includes grouped buckets:
497
+ Grouped views live under `px.scope`:
438
498
 
439
499
  ```ruby
440
- px[:path]
441
- px[:query]
442
- px[:body]
443
- px[:headers]
444
- px[:cookies]
500
+ px.scope[:path]
501
+ px.scope[:query]
502
+ px.scope[:body]
503
+ px.scope[:headers]
504
+ px.scope[:cookies]
445
505
  ```
446
506
 
447
507
  Notes:
448
508
 
449
- - root values from path/query/body are also flattened into `px[:name]`
509
+ - every declared field from path/query/body is also flattened into the top-level `px[:field]`
510
+ - custom `scope(:name)` buckets are also exposed through `px.scope[:name]`
511
+ - headers and cookies stay inside their own grouped buckets; for example, `px[:Authorization]` is not a top-level shortcut
450
512
  - header keys are stored in lowercase dashed form, but reading remains compatible with original forms such as `Authorization` and `HTTP_AUTHORIZATION`, for example:
451
513
 
452
514
  ```ruby
453
- px[:headers][:authorization]
454
- px[:headers]["Authorization"]
455
- px[:headers]["HTTP_AUTHORIZATION"]
515
+ px.scope[:headers][:authorization]
516
+ px.scope[:headers]["Authorization"]
517
+ px.scope[:headers]["HTTP_AUTHORIZATION"]
456
518
  ```
457
519
 
458
520
  - original `params` are not mutated
@@ -461,60 +523,43 @@ px[:headers]["HTTP_AUTHORIZATION"]
461
523
 
462
524
  Validation errors are stored in `ActiveModel::Errors`.
463
525
 
464
- If invalid parameters are not rescued, ActionSpec raises `ActionSpec::InvalidParameters`:
526
+ When validation fails, ActionSpec raises `ActionSpec::InvalidParameters`:
465
527
 
466
528
  ```ruby
467
529
  begin
468
530
  validate_and_coerce_params!
469
531
  rescue ActionSpec::InvalidParameters => error
532
+ error.message
470
533
  error.errors.full_messages
471
534
  end
472
535
  ```
473
536
 
474
537
  The exception also keeps the full validation result on `error.result` and `error.parameters`.
538
+ ActionSpec does not render a default error response for you, so each application can decide its own rescue and JSON format.
475
539
 
476
- ### Default Rescue Behavior
540
+ `error.message` is built from `error.errors.full_messages.to_sentence`, so it follows normal `ActiveModel::Errors` wording:
477
541
 
478
- By default, when a controller raises `ActionSpec::InvalidParameters`, ActionSpec catches it automatically and returns a JSON error response:
542
+ - single error: `"Page is required"`
543
+ - multiple errors: `"Page is required and Birthday must be a valid date"`
544
+ - fallback when no detailed errors are present: `"Invalid parameters"`
479
545
 
480
- ```ruby
481
- rescue_from ActionSpec::InvalidParameters
482
- ```
483
-
484
- The default JSON response is:
485
-
486
- ```json
487
- {
488
- "errors": {
489
- "page": ["Page is required"]
490
- }
491
- }
492
- ```
546
+ Use `error.errors` when you need structured details, and `error.message` when you only need a single summary string.
493
547
 
494
548
  ## Configuration And I18n
495
549
 
496
550
  ### Configuration
497
551
 
498
552
  ```ruby
499
- ActionSpec.configure do |config|
500
- config.rescue_invalid_parameters = true
501
- config.invalid_parameters_status = :bad_request
553
+ ActionSpec.configure { |config|
502
554
  config.open_api_output = "docs/openapi.yml"
503
555
  config.open_api_title = "My API"
504
556
  config.open_api_version = "2026.03"
505
557
  config.open_api_server_url = "https://api.example.com"
506
558
 
507
- config.error_messages[:invalid_type] = ->(_attribute, options) do
559
+ config.error_messages[:invalid_type] = ->(_attribute, options) {
508
560
  "should be coercible to #{options.fetch(:expected)}"
509
- end
510
-
511
- config.invalid_parameters_renderer = ->(controller, error) do
512
- controller.render json: {
513
- code: "invalid_parameters",
514
- errors: error.errors.to_hash(full_messages: true)
515
- }, status: :unprocessable_entity
516
- end
517
- end
561
+ }
562
+ }
518
563
  ```
519
564
 
520
565
  Available config keys:
@@ -523,18 +568,6 @@ Available config keys:
523
568
  Default: `ActionSpec::InvalidParameters`.
524
569
  Controls which exception class is raised when validation fails.
525
570
 
526
- - `invalid_parameters_status`
527
- Default: `:bad_request`.
528
- Controls the HTTP status used by the built-in `rescue_from` renderer.
529
-
530
- - `rescue_invalid_parameters`
531
- Default: `true`.
532
- When this option is enabled, controllers use the default `rescue_from ActionSpec::InvalidParameters`.
533
-
534
- - `invalid_parameters_renderer`
535
- Default: `nil`.
536
- Lets you replace the built-in JSON error response. It can be a proc receiving `(controller, error)`, or a block executed in controller context.
537
-
538
571
  - `error_messages`
539
572
  Default: `{}`.
540
573
  Lets you override error messages by error type, or by attribute plus error type.
@@ -574,20 +607,32 @@ en:
574
607
  You can also override messages per error type or per attribute in Ruby:
575
608
 
576
609
  ```ruby
577
- ActionSpec.configure do |config|
610
+ ActionSpec.configure { |config|
578
611
  config.error_messages[:required] = "must be present"
579
612
  config.error_messages[:invalid_type] = ->(_attribute, options) { "must be a valid #{options.fetch(:expected)}" }
580
613
  config.error_messages[:page] = {
581
614
  required: "page is mandatory"
582
615
  }
583
- end
616
+ }
584
617
  ```
585
618
 
619
+ ## AI Generation Style Guide
620
+
621
+ 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:
622
+
623
+ - 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
624
+ - use `{ }` blocks inside `doc` as well; prefer them over `do ... end`
625
+ - when a batch has 3 fields or fewer and does not contain nested hashes, prefer a single-line style, for example:
626
+ - `json data: { type: String, required: true }`
627
+ - `in_query(name: String, value: String)` (prefer `in_xxx(...)` batch declarations over multiple `xx` DSL lines when possible)
628
+ - use `doc_dry`, `scope`, and `px.slice` to reduce repetition in controllers
629
+ - when request parameters match model declarations, prefer `.schemas` to keep `doc` concise
630
+
586
631
  ## What Is Not Implemented Yet
587
632
 
588
633
  - reusable `components` generation
589
634
  - `$ref` generation and deduplication
590
- - `description`, `operationId`, `tags`, `externalDocs`, `deprecated`, and `security` on operations
635
+ - `description`, `externalDocs`, `deprecated`, and `security` on operations
591
636
  - parameter-level `style`, `explode`, `allowReserved`, `examples`, and richer header/cookie serialization controls
592
637
  - request body `encoding`
593
638
  - multiple request/response media types beyond the current direct DSL mapping
@@ -605,7 +650,8 @@ end
605
650
  - richer schema keywords beyond the current subset, including nullable/blank semantics, object-level constraints, and composition keywords such as `oneOf`, `anyOf`, `allOf`, and `not`
606
651
 
607
652
  ## Contributing
608
- .
653
+
654
+ Contributions / Issues are welcome.
609
655
 
610
656
  ## License
611
657
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -2,16 +2,12 @@
2
2
 
3
3
  module ActionSpec
4
4
  class Configuration
5
- attr_accessor :invalid_parameters_exception_class, :invalid_parameters_status, :rescue_invalid_parameters,
6
- :invalid_parameters_renderer, :open_api_output, :open_api_title, :open_api_version,
5
+ attr_accessor :invalid_parameters_exception_class, :open_api_output, :open_api_title, :open_api_version,
7
6
  :open_api_server_url
8
7
  attr_reader :error_messages
9
8
 
10
9
  def initialize
11
10
  @invalid_parameters_exception_class = ActionSpec::InvalidParameters
12
- @invalid_parameters_status = :bad_request
13
- @rescue_invalid_parameters = true
14
- @invalid_parameters_renderer = nil
15
11
  @open_api_output = "docs/openapi.yml"
16
12
  @open_api_title = nil
17
13
  @open_api_version = nil
@@ -33,9 +29,6 @@ module ActionSpec
33
29
  def dup
34
30
  self.class.new.tap do |copy|
35
31
  copy.invalid_parameters_exception_class = invalid_parameters_exception_class
36
- copy.invalid_parameters_status = invalid_parameters_status
37
- copy.rescue_invalid_parameters = rescue_invalid_parameters
38
- copy.invalid_parameters_renderer = invalid_parameters_renderer
39
32
  copy.open_api_output = open_api_output
40
33
  copy.open_api_title = open_api_title
41
34
  copy.open_api_version = open_api_version
@@ -7,6 +7,7 @@ module ActionSpec
7
7
 
8
8
  def initialize(endpoint)
9
9
  @endpoint = endpoint
10
+ @scopes = []
10
11
  end
11
12
 
12
13
  PARAM_LOCATIONS.each do |location_name|
@@ -55,6 +56,17 @@ module ActionSpec
55
56
  add_body(:form, { name => options.merge(type:) })
56
57
  end
57
58
 
59
+ def scope(name, &block)
60
+ scopes.push(name.to_sym)
61
+ instance_exec(&block)
62
+ ensure
63
+ scopes.pop
64
+ end
65
+
66
+ def openapi(enabled)
67
+ endpoint.options[:openapi] = enabled
68
+ end
69
+
58
70
  def response(code, description = nil, media_type = nil, desc: nil, **options)
59
71
  endpoint.add_response(
60
72
  code,
@@ -73,10 +85,11 @@ module ActionSpec
73
85
  private
74
86
 
75
87
  attr_reader :endpoint
88
+ attr_reader :scopes
76
89
 
77
90
  def add_param(location_name, name, type, required:, **options)
78
91
  schema = ActionSpec::Schema.build(type, **options)
79
- endpoint.request.add_param(location_name, ActionSpec::Schema::Field.new(name:, required:, schema:))
92
+ endpoint.request.add_param(location_name, ActionSpec::Schema::Field.new(name:, required:, schema:, scopes: scopes.dup))
80
93
  end
81
94
 
82
95
  def add_many(location_name, params, required:)
@@ -86,7 +99,12 @@ module ActionSpec
86
99
  if (schema_options.keys - ActionSpec::Schema::OPTION_KEYS).present?
87
100
  endpoint.request.add_param(
88
101
  location_name,
89
- ActionSpec::Schema::Field.new(name:, required:, schema: ActionSpec::Schema.from_definition(definition))
102
+ ActionSpec::Schema::Field.new(
103
+ name:,
104
+ required:,
105
+ schema: ActionSpec::Schema.from_definition(definition),
106
+ scopes: scopes.dup
107
+ )
90
108
  )
91
109
  else
92
110
  add_param(location_name, name, String, required:, **definition)
@@ -100,7 +118,7 @@ module ActionSpec
100
118
  end
101
119
 
102
120
  def add_body(media_type, definition)
103
- ActionSpec::Schema.build_fields(definition).each_value do |field|
121
+ ActionSpec::Schema.build_fields(definition, scopes: scopes.dup).each_value do |field|
104
122
  endpoint.request.add_body(media_type, field)
105
123
  end
106
124
  end
@@ -8,6 +8,8 @@ module ActionSpec
8
8
  extend ActiveSupport::Concern
9
9
 
10
10
  class_methods do
11
+ DryEntry = Struct.new(:block, :options, keyword_init: true)
12
+
11
13
  def action_specs
12
14
  @action_specs ||= begin
13
15
  parent = superclass.respond_to?(:action_specs) ? superclass.action_specs : {}
@@ -18,20 +20,24 @@ module ActionSpec
18
20
  def dry_blocks
19
21
  @dry_blocks ||= begin
20
22
  parent = superclass.respond_to?(:dry_blocks) ? superclass.dry_blocks : {}
21
- parent.transform_values { |blocks| blocks.map(&:dup) }
23
+ parent.transform_values do |entries|
24
+ entries.map { |entry| DryEntry.new(block: entry.block, options: entry.options.deep_dup) }
25
+ end
22
26
  end
23
27
  end
24
28
 
25
29
  def doc(action_or_summary = nil, summary = nil, **options, &block)
26
30
  action_name, endpoint_summary = normalize_doc_arguments(action_or_summary, summary)
27
31
  action_name ||= infer_action_name(caller_locations(1, 1).first)
28
- endpoint = Endpoint.new(action_name, summary: endpoint_summary, options:)
29
- action_specs[action_name.to_sym] = apply_dry_blocks(endpoint).apply(block || proc {})
32
+ endpoint = Endpoint.new(action_name, summary: endpoint_summary, options: {})
33
+ endpoint = apply_dry_blocks(endpoint)
34
+ endpoint.options.merge!(options)
35
+ action_specs[action_name.to_sym] = endpoint.apply(block || proc {})
30
36
  end
31
37
 
32
- def doc_dry(actions = :all, &block)
38
+ def doc_dry(actions = :all, **options, &block)
33
39
  Array(actions).each do |action|
34
- (dry_blocks[action.to_sym] ||= []) << block
40
+ (dry_blocks[action.to_sym] ||= []) << DryEntry.new(block:, options:)
35
41
  end
36
42
  end
37
43
  alias api_dry doc_dry
@@ -49,8 +55,9 @@ module ActionSpec
49
55
  end
50
56
 
51
57
  def apply_dry_blocks(endpoint)
52
- [*dry_blocks[:all], *dry_blocks[endpoint.action]].compact.each do |dry_block|
53
- endpoint.apply(dry_block)
58
+ [*dry_blocks[:all], *dry_blocks[endpoint.action]].compact.each do |entry|
59
+ endpoint.options.merge!(entry.options)
60
+ endpoint.apply(entry.block) if entry.block
54
61
  end
55
62
  endpoint
56
63
  end
@@ -8,8 +8,25 @@ module ActionSpec
8
8
  document = new(application:, routes:, title:, version:, server_url:).call
9
9
 
10
10
  FileUtils.mkdir_p(File.dirname(output))
11
- File.write(output, YAML.dump(document))
11
+ File.write(output, pretty_yaml(plain_data(document)))
12
12
  end
13
+
14
+ private
15
+
16
+ def pretty_yaml(document)
17
+ YAML.dump(document).gsub(/^(\s*)"\/([^"]+)":$/, '\1/\2:')
18
+ end
19
+
20
+ def plain_data(value)
21
+ case value
22
+ when Array
23
+ value.map { |item| plain_data(item) }
24
+ when Hash
25
+ value.each_with_object({}) { |(key, item), hash| hash[key] = plain_data(item) }
26
+ else
27
+ value
28
+ end
29
+ end
13
30
  end
14
31
 
15
32
  def initialize(application: nil, routes: nil, title: nil, version: nil, server_url: nil)
@@ -48,13 +65,14 @@ module ActionSpec
48
65
  next unless (controller = controller_for(route))
49
66
  next unless controller.respond_to?(:action_spec_for)
50
67
  next unless (endpoint = controller.action_spec_for(route_action(route)))
68
+ next if endpoint.options[:openapi] == false
51
69
 
52
70
  path = normalized_path(route)
53
71
  next if path.blank?
54
72
 
55
73
  hash[path] ||= ActiveSupport::OrderedHash.new
56
74
  route_verbs(route).each do |verb|
57
- hash[path][verb] = Operation.new(endpoint).build
75
+ hash[path][verb] = Operation.new(endpoint, controller_path: controller.controller_path).build
58
76
  end
59
77
  end
60
78
  end
@@ -3,14 +3,17 @@
3
3
  module ActionSpec
4
4
  module OpenApi
5
5
  class Operation
6
- def initialize(endpoint)
6
+ def initialize(endpoint, controller_path:)
7
7
  @endpoint = endpoint
8
+ @controller_path = controller_path
8
9
  @schema = Schema.new
9
10
  end
10
11
 
11
12
  def build
12
13
  {
13
14
  "summary" => endpoint.summary.presence,
15
+ "operationId" => operation_id,
16
+ "tags" => tags,
14
17
  "parameters" => parameters.presence,
15
18
  "requestBody" => schema.request_body(endpoint.request),
16
19
  "responses" => responses
@@ -19,7 +22,23 @@ module ActionSpec
19
22
 
20
23
  private
21
24
 
22
- attr_reader :endpoint, :schema
25
+ attr_reader :endpoint, :controller_path, :schema
26
+
27
+ def tags
28
+ [endpoint.options[:tag].presence || controller_path.presence].compact
29
+ end
30
+
31
+ def operation_id
32
+ [primary_tag, endpoint.action].compact.join("_")
33
+ end
34
+
35
+ def primary_tag
36
+ resolved_tag&.to_s&.tr("/", "_")
37
+ end
38
+
39
+ def resolved_tag
40
+ endpoint.options[:tag].presence || controller_path.presence
41
+ end
23
42
 
24
43
  def parameters
25
44
  %i[path query header cookie].flat_map do |location|
@@ -105,16 +105,23 @@ module ActionSpec
105
105
 
106
106
  def apply_common_options(definition, schema)
107
107
  definition["description"] = schema.description if schema.description.present?
108
- definition["default"] = schema.default unless schema.default.respond_to?(:call) || schema.default.nil?
108
+ apply_literal_option(definition, "default", schema.default) unless schema.default.respond_to?(:call)
109
109
  definition["enum"] = schema.enum if schema.enum.present?
110
110
  definition["pattern"] = regex_source(schema.pattern) if schema.pattern.present?
111
111
  apply_length(definition, schema.length, definition["type"])
112
- definition["example"] = schema.example if schema.example.present?
113
- definition["examples"] = schema.examples if schema.examples.present?
112
+ apply_literal_option(definition, "example", schema.example)
113
+ apply_literal_option(definition, "examples", schema.examples)
114
114
  apply_range(definition, schema.range)
115
115
  definition
116
116
  end
117
117
 
118
+ def apply_literal_option(definition, key, value)
119
+ normalized = openapi_literal(value)
120
+ return if normalized.nil? || normalized.equal?(invalid_openapi_literal)
121
+
122
+ definition[key] = normalized
123
+ end
124
+
118
125
  def apply_range(definition, range)
119
126
  return if range.blank?
120
127
 
@@ -166,6 +173,31 @@ module ActionSpec
166
173
  def regex_source(pattern)
167
174
  pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
168
175
  end
176
+
177
+ def openapi_literal(value)
178
+ case value
179
+ when nil, String, Integer, Float, TrueClass, FalseClass
180
+ value
181
+ when Array
182
+ normalized = value.map { |item| openapi_literal(item) }
183
+ return invalid_openapi_literal if normalized.any? { |item| item.equal?(invalid_openapi_literal) }
184
+
185
+ normalized
186
+ when Hash
187
+ value.each_with_object(ActiveSupport::OrderedHash.new) do |(key, item), normalized|
188
+ item = openapi_literal(item)
189
+ return invalid_openapi_literal if item.equal?(invalid_openapi_literal)
190
+
191
+ normalized[key.to_s] = item
192
+ end
193
+ else
194
+ invalid_openapi_literal
195
+ end
196
+ end
197
+
198
+ def invalid_openapi_literal
199
+ @invalid_openapi_literal ||= Object.new.freeze
200
+ end
169
201
  end
170
202
  end
171
203
  end
@@ -1,5 +1,9 @@
1
1
  module ActionSpec
2
2
  class Railtie < ::Rails::Railtie
3
+ rake_tasks do
4
+ load File.expand_path("../tasks/action_spec_tasks.rake", __dir__)
5
+ end
6
+
3
7
  initializer "action_spec.controller" do
4
8
  ActiveSupport.on_load(:action_controller_base) do
5
9
  include ActionSpec::Doc
@@ -3,12 +3,13 @@
3
3
  module ActionSpec
4
4
  module Schema
5
5
  class Field
6
- attr_reader :name, :schema
6
+ attr_reader :name, :schema, :scopes
7
7
 
8
- def initialize(name:, required:, schema:)
8
+ def initialize(name:, required:, schema:, scopes: [])
9
9
  @name = name.to_sym
10
10
  @required = required
11
11
  @schema = schema
12
+ @scopes = Array(scopes).map(&:to_sym).freeze
12
13
  end
13
14
 
14
15
  def required?
@@ -20,7 +21,7 @@ module ActionSpec
20
21
  end
21
22
 
22
23
  def copy
23
- self.class.new(name:, required: required?, schema: schema.copy)
24
+ self.class.new(name:, required: required?, schema: schema.copy, scopes:)
24
25
  end
25
26
  end
26
27
  end
@@ -40,13 +40,14 @@ module ActionSpec
40
40
  ObjectOf.new(build_fields(definition))
41
41
  end
42
42
 
43
- def build_fields(definition_hash)
43
+ def build_fields(definition_hash, scopes: [])
44
44
  definition_hash.each_with_object(ActiveSupport::OrderedHash.new) do |(name, definition), fields|
45
45
  schema = build_field_schema(definition)
46
46
  fields[field_name(name)] = Field.new(
47
47
  name: field_name(name),
48
48
  required: required_key?(name),
49
- schema:
49
+ schema:,
50
+ scopes:
50
51
  )
51
52
  end
52
53
  end
@@ -5,26 +5,29 @@ module ActionSpec
5
5
  extend ActiveModel::Naming
6
6
  extend ActiveModel::Translation
7
7
 
8
+ BUILT_IN_SCOPES = %i[path query body headers cookies].freeze
9
+
10
+ class << self
11
+ def empty_px
12
+ new.px
13
+ end
14
+ end
15
+
8
16
  attr_reader :errors, :px
9
17
 
10
18
  def initialize
11
19
  @errors = ActiveModel::Errors.new(self)
12
- @px = ActiveSupport::HashWithIndifferentAccess.new(
13
- path: ActiveSupport::HashWithIndifferentAccess.new,
14
- query: ActiveSupport::HashWithIndifferentAccess.new,
15
- body: ActiveSupport::HashWithIndifferentAccess.new,
16
- headers: HeaderHash.new,
17
- cookies: ActiveSupport::HashWithIndifferentAccess.new
18
- )
20
+ @px = build_px
19
21
  end
20
22
 
21
23
  def invalid?
22
24
  errors.any?
23
25
  end
24
26
 
25
- def assign(location, key, value)
27
+ def assign(location, key, value, scopes: [])
26
28
  bucket(location)[key] = value
27
29
  px[key] = value if root_bucket?(location)
30
+ Array(scopes).each { |scope_name| scope_bucket(scope_name)[key] = value }
28
31
  end
29
32
 
30
33
  def add_error(attribute, type, **options)
@@ -60,8 +63,28 @@ module ActionSpec
60
63
 
61
64
  private
62
65
 
66
+ def build_px
67
+ values = ActiveSupport::HashWithIndifferentAccess.new
68
+ scope = ActiveSupport::HashWithIndifferentAccess.new
69
+
70
+ BUILT_IN_SCOPES.each do |scope_name|
71
+ bucket = scope_name == :headers ? HeaderHash.new : ActiveSupport::HashWithIndifferentAccess.new
72
+ values[scope_name] = bucket
73
+ scope[scope_name] = bucket
74
+ end
75
+
76
+ # Keep px hash-like while exposing grouped views through px.scope.
77
+ values.instance_variable_set(:@scope, scope)
78
+ values.define_singleton_method(:scope) { @scope }
79
+ values
80
+ end
81
+
63
82
  def bucket(location)
64
- px.fetch(location)
83
+ px.scope.fetch(location)
84
+ end
85
+
86
+ def scope_bucket(name)
87
+ px.scope[name] ||= ActiveSupport::HashWithIndifferentAccess.new
65
88
  end
66
89
 
67
90
  def root_bucket?(location)
@@ -28,7 +28,7 @@ module ActionSpec
28
28
  value = resolve_field(field, result:, source:, location:)
29
29
  next if value.equal?(ActionSpec::Schema::Missing)
30
30
 
31
- result.assign(location, storage_key(field, location), value)
31
+ result.assign(location, storage_key(field, location), value, scopes: field.scopes)
32
32
  end
33
33
  end
34
34
 
@@ -6,12 +6,8 @@ module ActionSpec
6
6
  module Validator
7
7
  extend ActiveSupport::Concern
8
8
 
9
- included do
10
- rescue_from ActionSpec::InvalidParameters, with: :render_invalid_parameters if ActionSpec.config.rescue_invalid_parameters
11
- end
12
-
13
9
  def px
14
- @px ||= ActiveSupport::HashWithIndifferentAccess.new
10
+ @px ||= ValidationResult.empty_px
15
11
  end
16
12
 
17
13
  def validate_params!
@@ -26,21 +22,12 @@ module ActionSpec
26
22
 
27
23
  def validate_with(coerce:)
28
24
  endpoint = self.class.respond_to?(:action_spec_for) ? self.class.action_spec_for(action_name) : nil
29
- return ActiveSupport::HashWithIndifferentAccess.new unless endpoint
25
+ return ValidationResult.empty_px unless endpoint
30
26
 
31
27
  result = Runner.new(endpoint:, controller: self, coerce:).call
32
28
  raise ActionSpec.config.invalid_parameters_exception_class.new(result) if result.invalid?
33
29
 
34
30
  result.px
35
31
  end
36
-
37
- def render_invalid_parameters(error)
38
- if (renderer = ActionSpec.config.invalid_parameters_renderer)
39
- return renderer.arity == 2 ? renderer.call(self, error) : instance_exec(error, &renderer)
40
- end
41
-
42
- render json: { errors: error.errors.to_hash(full_messages: true) },
43
- status: ActionSpec.config.invalid_parameters_status
44
- end
45
32
  end
46
33
  end
@@ -1,3 +1,3 @@
1
1
  module ActionSpec
2
- VERSION = "1.1.0"
2
+ VERSION = "1.3.0"
3
3
  end
@@ -2,13 +2,16 @@ namespace :action_spec do
2
2
  desc "Generate an OpenAPI 3.2 document from ActionSpec controller docs"
3
3
  task gen: :environment do
4
4
  config = ActionSpec.config
5
+ output = Rails.root.join(ENV.fetch("OUTPUT", config.open_api_output)).to_s
5
6
 
6
7
  ActionSpec::OpenApi::Generator.generate!(
7
8
  application: Rails.application,
8
- output: Rails.root.join(ENV.fetch("OUTPUT", config.open_api_output)).to_s,
9
+ output:,
9
10
  title: ENV["TITLE"].presence || config.open_api_title,
10
11
  version: ENV["VERSION"].presence || config.open_api_version,
11
12
  server_url: ENV["SERVER_URL"].presence || config.open_api_server_url
12
13
  )
14
+
15
+ puts "Generated OpenAPI document: #{output}"
13
16
  end
14
17
  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.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhandao
@@ -53,8 +53,6 @@ files:
53
53
  - MIT-LICENSE
54
54
  - README.md
55
55
  - Rakefile
56
- - config/locales/en.yml
57
- - config/locales/zh.yml
58
56
  - lib/action_spec.rb
59
57
  - lib/action_spec/configuration.rb
60
58
  - lib/action_spec/doc.rb
@@ -1,6 +0,0 @@
1
- en:
2
- activemodel:
3
- errors:
4
- messages:
5
- required: "is required"
6
- invalid_type: "must be a valid %{expected}"
@@ -1,6 +0,0 @@
1
- zh:
2
- activemodel:
3
- errors:
4
- messages:
5
- required: "不能为空"
6
- invalid_type: "必须是有效的%{expected}"