action_spec 1.2.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: a01ddc76d37b180c1564c96963ccce0bbe32cc9efd4c29deede6e1c3e7a8f7c7
4
- data.tar.gz: a97df2927f2f5df0fce593b2ff55fa123d983ab6023690326bce9634ead51b31
3
+ metadata.gz: 02c55ca1816d1b61ad17a4a735c1704bb887cc4f67d842a7ed351641c47e93d8
4
+ data.tar.gz: a148b5f034a92b4f6648c3deb5e29aa997588e6162cd761e3586e9840d6b0fa2
5
5
  SHA512:
6
- metadata.gz: e5ba8be1ae92c054686603867e11cda5b1af8482113ae2fcd4b36303e9631f1bdfd3c915b5e724a84dcd3c20c29f2a3f46bb92f74654716c3037625125d00820
7
- data.tar.gz: 0d8327b6f04162da2ceedf697ac0a0e3dde789584870ebde8c2769ac43e6bb00836a81386b61d519b2472cd4acd8ffaf19472bbd0490c7911f9295c5216d4ed4
6
+ metadata.gz: 1eb2aa363f52d5800497364315168186b3fb04fc0fc1541ebe2075a73284585a5302b68f48183f5163451607f53bddc42474a988a6549dcb4daf7849bd2bf9aa
7
+ data.tar.gz: 985e5bd7551b4f6bd9919209137c90cae86a3a2720113dacb01e421849fca4e16a602550fb833a920d703f997c97a2b8b87c49ceda5ce4ecefa9bfea1a641988
data/README.md CHANGED
@@ -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)
@@ -28,6 +30,7 @@ Concise and Powerful API Documentation Solution for Rails.
28
30
  - [Configuration And I18n](#configuration-and-i18n)
29
31
  - [Configuration](#configuration)
30
32
  - [I18n](#i18n)
33
+ - [AI Generation Style Guide](#ai-generation-style-guide)
31
34
 
32
35
  ## Example
33
36
 
@@ -163,6 +166,8 @@ end
163
166
 
164
167
  All matching dry blocks are applied before the action-specific `doc`.
165
168
 
169
+ ### `openapi false`
170
+
166
171
  You can also opt an action out of OpenAPI generation from either `doc` or `doc_dry`:
167
172
 
168
173
  ```ruby
@@ -171,6 +176,20 @@ doc {
171
176
  }
172
177
  ```
173
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
+
174
193
  ### DSL Reference
175
194
 
176
195
  #### Parameter
@@ -465,6 +484,7 @@ This hook also skips actions without a matching `doc`, so it is safe to declare
465
484
  ### Reading Processed Values With `px`
466
485
 
467
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.
468
488
 
469
489
  ```ruby
470
490
  px[:id]
@@ -596,11 +616,23 @@ ActionSpec.configure { |config|
596
616
  }
597
617
  ```
598
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
+
599
631
  ## What Is Not Implemented Yet
600
632
 
601
633
  - reusable `components` generation
602
634
  - `$ref` generation and deduplication
603
- - `description`, `operationId`, `tags`, `externalDocs`, `deprecated`, and `security` on operations
635
+ - `description`, `externalDocs`, `deprecated`, and `security` on operations
604
636
  - parameter-level `style`, `explode`, `allowReserved`, `examples`, and richer header/cookie serialization controls
605
637
  - request body `encoding`
606
638
  - multiple request/response media types beyond the current direct DSL mapping
@@ -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)
@@ -55,7 +72,7 @@ module ActionSpec
55
72
 
56
73
  hash[path] ||= ActiveSupport::OrderedHash.new
57
74
  route_verbs(route).each do |verb|
58
- hash[path][verb] = Operation.new(endpoint).build
75
+ hash[path][verb] = Operation.new(endpoint, controller_path: controller.controller_path).build
59
76
  end
60
77
  end
61
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
@@ -1,3 +1,3 @@
1
1
  module ActionSpec
2
- VERSION = "1.2.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.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhandao