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 +4 -4
- data/README.md +111 -65
- data/lib/action_spec/configuration.rb +1 -8
- data/lib/action_spec/doc/dsl.rb +21 -3
- data/lib/action_spec/doc.rb +14 -7
- data/lib/action_spec/open_api/generator.rb +20 -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/schema/field.rb +4 -3
- data/lib/action_spec/schema.rb +3 -2
- data/lib/action_spec/validation_result.rb +32 -9
- data/lib/action_spec/validator/runner.rb +1 -1
- data/lib/action_spec/validator.rb +2 -15
- data/lib/action_spec/version.rb +1 -1
- data/lib/tasks/action_spec_tasks.rake +4 -1
- metadata +1 -3
- data/config/locales/en.yml +0 -6
- data/config/locales/zh.yml +0 -6
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
|
@@ -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
|
|
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
|
|
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
|
|
156
|
+
doc_dry(%i[show update destroy]) {
|
|
154
157
|
path! :id, Integer
|
|
155
|
-
|
|
158
|
+
}
|
|
156
159
|
|
|
157
|
-
doc_dry
|
|
160
|
+
doc_dry(:index) {
|
|
158
161
|
query :page, Integer, default: 1
|
|
159
162
|
query :per, Integer, default: 20
|
|
160
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
540
|
+
`error.message` is built from `error.errors.full_messages.to_sentence`, so it follows normal `ActiveModel::Errors` wording:
|
|
477
541
|
|
|
478
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
559
|
+
config.error_messages[:invalid_type] = ->(_attribute, options) {
|
|
508
560
|
"should be coercible to #{options.fetch(:expected)}"
|
|
509
|
-
|
|
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
|
|
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
|
-
|
|
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`, `
|
|
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, :
|
|
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
|
data/lib/action_spec/doc/dsl.rb
CHANGED
|
@@ -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(
|
|
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
|
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)
|
|
@@ -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
|
|
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
|
|
@@ -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
|
data/lib/action_spec/schema.rb
CHANGED
|
@@ -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 =
|
|
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 ||=
|
|
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
|
|
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
|
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
|
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.
|
|
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
|
data/config/locales/en.yml
DELETED