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 +4 -4
- data/README.md +33 -1
- data/lib/action_spec/doc.rb +14 -7
- data/lib/action_spec/open_api/generator.rb +19 -2
- data/lib/action_spec/open_api/operation.rb +21 -2
- data/lib/action_spec/open_api/schema.rb +35 -3
- data/lib/action_spec/railtie.rb +4 -0
- data/lib/action_spec/version.rb +1 -1
- data/lib/tasks/action_spec_tasks.rake +4 -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: 02c55ca1816d1b61ad17a4a735c1704bb887cc4f67d842a7ed351641c47e93d8
|
|
4
|
+
data.tar.gz: a148b5f034a92b4f6648c3deb5e29aa997588e6162cd761e3586e9840d6b0fa2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`, `
|
|
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
|
data/lib/action_spec/doc.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 |
|
|
53
|
-
endpoint.
|
|
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,
|
|
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
|
|
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
|
|
113
|
-
definition
|
|
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
|
data/lib/action_spec/railtie.rb
CHANGED
|
@@ -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
|
data/lib/action_spec/version.rb
CHANGED
|
@@ -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
|
|
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
|