action_spec 0.1.0 → 0.2.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 +94 -33
- data/lib/action_spec/configuration.rb +10 -1
- data/lib/action_spec/open_api/document.rb +32 -0
- data/lib/action_spec/open_api/generator.rb +119 -0
- data/lib/action_spec/open_api/operation.rb +41 -0
- data/lib/action_spec/open_api/schema.rb +160 -0
- data/lib/action_spec/open_api.rb +8 -0
- data/lib/action_spec/schema/array_of.rb +1 -1
- data/lib/action_spec/schema/base.rb +4 -1
- data/lib/action_spec/schema/object_of.rb +1 -1
- data/lib/action_spec/schema/scalar.rb +1 -1
- data/lib/action_spec/version.rb +1 -1
- data/lib/action_spec.rb +1 -0
- data/lib/tasks/action_spec_tasks.rake +14 -4
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf40844c78d8e8281f129c65e12bf07d9fc8ebabc18bd47c717d779cbc90fa99
|
|
4
|
+
data.tar.gz: 1552ea6a7da3f13b02febfd4eea39b05b437db5efaa93bb99320662b9f96e98c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 02e508bf9545f6848c351d96e7b95d4b7f22219322d365bba632f793722899922ee9ef347c147d4c98987cc9bf9b1ef18f70436ba105a7e695d0bb96f92acb47
|
|
7
|
+
data.tar.gz: cb9023dc36b73d1c155854200d726eb3fe3cb1dea03734da8fde6f64cf93cf58a2376eba90e77ccaff66c29ecbda220c6376037608a5d9fb2c0be702192c51f4
|
data/README.md
CHANGED
|
@@ -8,19 +8,28 @@ Concise and Powerful API Documentation Solution for Rails.
|
|
|
8
8
|
- Requires: Ruby 3.1+ and Rails 7.0+
|
|
9
9
|
- Note: this project was implemented with Codex in about one hour, 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
|
-
##
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
11
|
+
## Table Of Contents
|
|
12
|
+
|
|
13
|
+
- [OpenAPI Generation](#openapi-generation)
|
|
14
|
+
- [Doc DSL](#doc-dsl)
|
|
15
|
+
- [`doc`](#doc)
|
|
16
|
+
- [`doc_dry`](#doc_dry)
|
|
17
|
+
- [DSL Reference](#dsl-reference)
|
|
18
|
+
- [Schemas](#schemas)
|
|
19
|
+
- [Required Fields](#required-fields)
|
|
20
|
+
- [Supported Runtime Types](#supported-runtime-types)
|
|
21
|
+
- [Type And Boundary Matrix](#type-and-boundary-matrix)
|
|
22
|
+
- [Supported Runtime Options](#supported-runtime-options)
|
|
23
|
+
- [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
|
|
24
|
+
- [Validation Flow](#validation-flow)
|
|
25
|
+
- [Reading Validated Values With `px`](#reading-validated-values-with-px)
|
|
26
|
+
- [Errors](#errors)
|
|
27
|
+
- [Default Rescue Behavior](#default-rescue-behavior)
|
|
28
|
+
- [Configuration And I18n](#configuration-and-i18n)
|
|
29
|
+
- [Configuration](#configuration)
|
|
30
|
+
- [I18n](#i18n)
|
|
31
|
+
|
|
32
|
+
## Example
|
|
24
33
|
|
|
25
34
|
```ruby
|
|
26
35
|
class UsersController < ApplicationController
|
|
@@ -32,7 +41,7 @@ class UsersController < ApplicationController
|
|
|
32
41
|
query :locale, String, default: "zh-CN"
|
|
33
42
|
query :page, Integer, default: -> { 1 }
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
form data: {
|
|
36
45
|
name!: String,
|
|
37
46
|
age: Integer,
|
|
38
47
|
birthday: Date,
|
|
@@ -69,25 +78,58 @@ Then run:
|
|
|
69
78
|
$ bundle
|
|
70
79
|
```
|
|
71
80
|
|
|
72
|
-
##
|
|
81
|
+
## OpenAPI Generation
|
|
82
|
+
|
|
83
|
+
Generate an OpenAPI document from the current Rails routes and ActionSpec controller docs:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
bin/rails action_spec:gen
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
By default, this writes to:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
docs/openapi.yml
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
For one-off runs, environment variables can override the default output path and document metadata:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
bin/rails action_spec:gen \
|
|
99
|
+
OUTPUT=docs/openapi.yml \
|
|
100
|
+
TITLE="My API" \
|
|
101
|
+
VERSION="2026.03" \
|
|
102
|
+
SERVER_URL="https://api.example.com"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Notes:
|
|
106
|
+
|
|
107
|
+
- only routed controller actions with a matching `doc` declaration are included
|
|
108
|
+
- Rails paths such as `/users/:id(.:format)` are rendered as `/users/{id}`
|
|
109
|
+
- parameters, request bodies, and response descriptions are generated from the current DSL support
|
|
110
|
+
- if config and environment variables do not provide `TITLE` or `VERSION`, ActionSpec falls back to application-derived defaults
|
|
111
|
+
|
|
112
|
+
## Doc DSL
|
|
73
113
|
|
|
74
|
-
###
|
|
114
|
+
### `doc`
|
|
75
115
|
|
|
76
|
-
|
|
116
|
+
With action inferred from the next instance method:
|
|
77
117
|
|
|
78
118
|
```ruby
|
|
79
119
|
doc {
|
|
80
|
-
|
|
120
|
+
form data: { # <= request body DSL
|
|
121
|
+
name!: String # <= schema DSL
|
|
122
|
+
}
|
|
81
123
|
}
|
|
82
124
|
def create
|
|
83
125
|
end
|
|
84
126
|
```
|
|
85
127
|
|
|
86
|
-
|
|
128
|
+
Provide a summary:
|
|
87
129
|
|
|
88
130
|
```ruby
|
|
89
131
|
doc("Create user") {
|
|
90
|
-
|
|
132
|
+
form data: { name!: String }
|
|
91
133
|
}
|
|
92
134
|
def create
|
|
93
135
|
end
|
|
@@ -97,13 +139,13 @@ You can also bind it explicitly when you want the action name declared in place:
|
|
|
97
139
|
|
|
98
140
|
```ruby
|
|
99
141
|
doc(:create, "Create user") {
|
|
100
|
-
|
|
142
|
+
form data: { name!: String }
|
|
101
143
|
}
|
|
102
144
|
def create
|
|
103
145
|
end
|
|
104
146
|
```
|
|
105
147
|
|
|
106
|
-
###
|
|
148
|
+
### `doc_dry`
|
|
107
149
|
|
|
108
150
|
```ruby
|
|
109
151
|
class ApplicationController < ActionController::API
|
|
@@ -122,11 +164,7 @@ All matching dry blocks are applied before the action-specific `doc`.
|
|
|
122
164
|
|
|
123
165
|
### DSL Reference
|
|
124
166
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
#### Parameter Locations
|
|
128
|
-
|
|
129
|
-
Single-parameter forms:
|
|
167
|
+
#### Parameter
|
|
130
168
|
|
|
131
169
|
```ruby
|
|
132
170
|
header :Authorization, String
|
|
@@ -171,7 +209,7 @@ in_query!(
|
|
|
171
209
|
)
|
|
172
210
|
```
|
|
173
211
|
|
|
174
|
-
####
|
|
212
|
+
#### request body
|
|
175
213
|
|
|
176
214
|
General form:
|
|
177
215
|
|
|
@@ -199,7 +237,7 @@ data :file, File
|
|
|
199
237
|
|
|
200
238
|
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.
|
|
201
239
|
|
|
202
|
-
#### Response
|
|
240
|
+
#### Response
|
|
203
241
|
|
|
204
242
|
```ruby
|
|
205
243
|
response 200, desc: "success"
|
|
@@ -210,7 +248,7 @@ error 401, "unauthorized"
|
|
|
210
248
|
|
|
211
249
|
Response declarations are stored as metadata now. They are not yet used to render responses automatically.
|
|
212
250
|
|
|
213
|
-
|
|
251
|
+
## Schemas
|
|
214
252
|
|
|
215
253
|
#### Required Fields
|
|
216
254
|
|
|
@@ -241,8 +279,7 @@ Scalar types currently supported by validation/coercion:
|
|
|
241
279
|
- `Integer`
|
|
242
280
|
- `Float`
|
|
243
281
|
- `BigDecimal`
|
|
244
|
-
- `:boolean`
|
|
245
|
-
- host-defined `Boolean` constant, if the host app already defines one
|
|
282
|
+
- `:boolean` / `Boolean`
|
|
246
283
|
- `Date`
|
|
247
284
|
- `DateTime`
|
|
248
285
|
- `Time`
|
|
@@ -298,6 +335,8 @@ These options are currently accepted as metadata, mainly for future OpenAPI work
|
|
|
298
335
|
- `allow_nil`
|
|
299
336
|
- `allow_blank`
|
|
300
337
|
|
|
338
|
+
## Parameter Validation And Type Coercion
|
|
339
|
+
|
|
301
340
|
### Validation Flow
|
|
302
341
|
|
|
303
342
|
#### `validate_params!`
|
|
@@ -396,12 +435,18 @@ The default JSON response is:
|
|
|
396
435
|
}
|
|
397
436
|
```
|
|
398
437
|
|
|
438
|
+
## Configuration And I18n
|
|
439
|
+
|
|
399
440
|
### Configuration
|
|
400
441
|
|
|
401
442
|
```ruby
|
|
402
443
|
ActionSpec.configure do |config|
|
|
403
444
|
config.rescue_invalid_parameters = true
|
|
404
445
|
config.invalid_parameters_status = :bad_request
|
|
446
|
+
config.open_api_output = "docs/openapi.yml"
|
|
447
|
+
config.open_api_title = "My API"
|
|
448
|
+
config.open_api_version = "2026.03"
|
|
449
|
+
config.open_api_server_url = "https://api.example.com"
|
|
405
450
|
|
|
406
451
|
config.error_messages[:invalid_type] = ->(_attribute, options) do
|
|
407
452
|
"should be coercible to #{options.fetch(:expected)}"
|
|
@@ -438,6 +483,22 @@ Available config keys:
|
|
|
438
483
|
Default: `{}`.
|
|
439
484
|
Lets you override error messages by error type, or by attribute plus error type.
|
|
440
485
|
|
|
486
|
+
- `open_api_output`
|
|
487
|
+
Default: `"docs/openapi.yml"`.
|
|
488
|
+
Controls where `bin/rails action_spec:gen` writes the generated OpenAPI document.
|
|
489
|
+
|
|
490
|
+
- `open_api_title`
|
|
491
|
+
Default: `nil`.
|
|
492
|
+
Sets the default OpenAPI `info.title` used by `bin/rails action_spec:gen`.
|
|
493
|
+
|
|
494
|
+
- `open_api_version`
|
|
495
|
+
Default: `nil`.
|
|
496
|
+
Sets the default OpenAPI `info.version` used by `bin/rails action_spec:gen`.
|
|
497
|
+
|
|
498
|
+
- `open_api_server_url`
|
|
499
|
+
Default: `nil`.
|
|
500
|
+
Sets the default server URL emitted in the generated OpenAPI document.
|
|
501
|
+
|
|
441
502
|
### I18n
|
|
442
503
|
|
|
443
504
|
ActionSpec loads its own locale files and uses `ActiveModel::Errors`, so you can override both messages and attribute names:
|
|
@@ -466,7 +527,7 @@ ActionSpec.configure do |config|
|
|
|
466
527
|
end
|
|
467
528
|
```
|
|
468
529
|
|
|
469
|
-
|
|
530
|
+
## What Is Not Implemented Yet
|
|
470
531
|
|
|
471
532
|
- OpenAPI document generation
|
|
472
533
|
- automatic response rendering from `response`
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :invalid_parameters_exception_class, :invalid_parameters_status, :rescue_invalid_parameters,
|
|
6
|
-
:invalid_parameters_renderer
|
|
6
|
+
:invalid_parameters_renderer, :open_api_output, :open_api_title, :open_api_version,
|
|
7
|
+
:open_api_server_url
|
|
7
8
|
attr_reader :error_messages
|
|
8
9
|
|
|
9
10
|
def initialize
|
|
@@ -11,6 +12,10 @@ module ActionSpec
|
|
|
11
12
|
@invalid_parameters_status = :bad_request
|
|
12
13
|
@rescue_invalid_parameters = true
|
|
13
14
|
@invalid_parameters_renderer = nil
|
|
15
|
+
@open_api_output = "docs/openapi.yml"
|
|
16
|
+
@open_api_title = nil
|
|
17
|
+
@open_api_version = nil
|
|
18
|
+
@open_api_server_url = nil
|
|
14
19
|
@error_messages = ActiveSupport::HashWithIndifferentAccess.new
|
|
15
20
|
end
|
|
16
21
|
|
|
@@ -31,6 +36,10 @@ module ActionSpec
|
|
|
31
36
|
copy.invalid_parameters_status = invalid_parameters_status
|
|
32
37
|
copy.rescue_invalid_parameters = rescue_invalid_parameters
|
|
33
38
|
copy.invalid_parameters_renderer = invalid_parameters_renderer
|
|
39
|
+
copy.open_api_output = open_api_output
|
|
40
|
+
copy.open_api_title = open_api_title
|
|
41
|
+
copy.open_api_version = open_api_version
|
|
42
|
+
copy.open_api_server_url = open_api_server_url
|
|
34
43
|
copy.error_messages = error_messages.deep_dup
|
|
35
44
|
end
|
|
36
45
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module OpenApi
|
|
5
|
+
class Document
|
|
6
|
+
OPENAPI_VERSION = "3.2.0"
|
|
7
|
+
|
|
8
|
+
def initialize(title:, version:, server_url: nil)
|
|
9
|
+
@title = title
|
|
10
|
+
@version = version
|
|
11
|
+
@server_url = server_url
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build(paths:)
|
|
15
|
+
{
|
|
16
|
+
"openapi" => OPENAPI_VERSION,
|
|
17
|
+
"info" => {
|
|
18
|
+
"title" => title,
|
|
19
|
+
"version" => version
|
|
20
|
+
},
|
|
21
|
+
"paths" => paths
|
|
22
|
+
}.tap do |document|
|
|
23
|
+
document["servers"] = [{ "url" => server_url }] if server_url.present?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :title, :version, :server_url
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module OpenApi
|
|
5
|
+
class Generator
|
|
6
|
+
class << self
|
|
7
|
+
def generate!(application: nil, routes: nil, output:, title: nil, version: nil, server_url: nil)
|
|
8
|
+
document = new(application:, routes:, title:, version:, server_url:).call
|
|
9
|
+
|
|
10
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
11
|
+
File.write(output, YAML.dump(document))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(application: nil, routes: nil, title: nil, version: nil, server_url: nil)
|
|
16
|
+
@application = application
|
|
17
|
+
@routes = routes
|
|
18
|
+
@title = title
|
|
19
|
+
@version = version
|
|
20
|
+
@server_url = server_url
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
Document.new(
|
|
25
|
+
title: resolved_title,
|
|
26
|
+
version: resolved_version,
|
|
27
|
+
server_url:
|
|
28
|
+
).build(paths:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :application, :routes, :title, :version, :server_url
|
|
34
|
+
|
|
35
|
+
def resolved_title
|
|
36
|
+
return title if title.present?
|
|
37
|
+
|
|
38
|
+
application_name = application&.class&.name.to_s.sub(/::Application\z/, "").sub(/Application\z/, "")
|
|
39
|
+
application_name.demodulize.titleize.presence || "API"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolved_version
|
|
43
|
+
version.presence || "1.0.0"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def paths
|
|
47
|
+
route_definitions.each_with_object(ActiveSupport::OrderedHash.new) do |route, hash|
|
|
48
|
+
next unless (controller = controller_for(route))
|
|
49
|
+
next unless controller.respond_to?(:action_spec_for)
|
|
50
|
+
next unless (endpoint = controller.action_spec_for(route_action(route)))
|
|
51
|
+
|
|
52
|
+
path = normalized_path(route)
|
|
53
|
+
next if path.blank?
|
|
54
|
+
|
|
55
|
+
hash[path] ||= ActiveSupport::OrderedHash.new
|
|
56
|
+
hash[path][route_verb(route)] = Operation.new(endpoint).build
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def route_definitions
|
|
61
|
+
return routes if routes
|
|
62
|
+
|
|
63
|
+
application.routes.routes
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def controller_for(route)
|
|
67
|
+
controller_name = route_defaults(route)[:controller].presence
|
|
68
|
+
return unless controller_name
|
|
69
|
+
|
|
70
|
+
"#{controller_name.camelize}Controller".safe_constantize
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def route_action(route)
|
|
74
|
+
route_defaults(route).fetch(:action).to_sym
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def route_defaults(route)
|
|
78
|
+
defaults =
|
|
79
|
+
if route.respond_to?(:defaults)
|
|
80
|
+
route.defaults
|
|
81
|
+
elsif route.respond_to?(:requirements)
|
|
82
|
+
route.requirements
|
|
83
|
+
else
|
|
84
|
+
route[:defaults]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
defaults.to_h.symbolize_keys
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def route_verb(route)
|
|
91
|
+
raw_verb =
|
|
92
|
+
if route.respond_to?(:verb) && route.verb.respond_to?(:source)
|
|
93
|
+
route.verb.source
|
|
94
|
+
elsif route.respond_to?(:verb)
|
|
95
|
+
route.verb.to_s
|
|
96
|
+
else
|
|
97
|
+
route[:verb].to_s
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
raw_verb.gsub(/[$^]/, "").split("|").find(&:present?).to_s.downcase
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def normalized_path(route)
|
|
104
|
+
raw_path =
|
|
105
|
+
if route.respond_to?(:path) && route.path.respond_to?(:spec)
|
|
106
|
+
route.path.spec.to_s
|
|
107
|
+
elsif route.respond_to?(:path)
|
|
108
|
+
route.path.to_s
|
|
109
|
+
else
|
|
110
|
+
route[:path].to_s
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
raw_path
|
|
114
|
+
.sub(/\(\.:format\)\z/, "")
|
|
115
|
+
.gsub(/:(\w+)/, '{\1}')
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module OpenApi
|
|
5
|
+
class Operation
|
|
6
|
+
def initialize(endpoint)
|
|
7
|
+
@endpoint = endpoint
|
|
8
|
+
@schema = Schema.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def build
|
|
12
|
+
{
|
|
13
|
+
"summary" => endpoint.summary.presence,
|
|
14
|
+
"parameters" => parameters.presence,
|
|
15
|
+
"requestBody" => schema.request_body(endpoint.request),
|
|
16
|
+
"responses" => responses
|
|
17
|
+
}.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :endpoint, :schema
|
|
23
|
+
|
|
24
|
+
def parameters
|
|
25
|
+
%i[path query header cookie].flat_map do |location|
|
|
26
|
+
endpoint.request.public_send(location).fields.map do |field|
|
|
27
|
+
schema.parameter(field, location:)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def responses
|
|
33
|
+
return { "200" => { "description" => "OK" } } if endpoint.responses.empty?
|
|
34
|
+
|
|
35
|
+
endpoint.responses.each_with_object(ActiveSupport::OrderedHash.new) do |(code, response), hash|
|
|
36
|
+
hash[code] = { "description" => response.description.presence || "OK" }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module OpenApi
|
|
5
|
+
class Schema
|
|
6
|
+
LOCATION_MAP = {
|
|
7
|
+
header: "header",
|
|
8
|
+
path: "path",
|
|
9
|
+
query: "query",
|
|
10
|
+
cookie: "cookie"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
MEDIA_TYPE_MAP = {
|
|
14
|
+
json: "application/json",
|
|
15
|
+
form: "multipart/form-data"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def parameter(field, location:)
|
|
19
|
+
{
|
|
20
|
+
"name" => parameter_name(field, location),
|
|
21
|
+
"in" => LOCATION_MAP.fetch(location),
|
|
22
|
+
"required" => location == :path ? true : field.required?,
|
|
23
|
+
"schema" => schema_for(field.schema)
|
|
24
|
+
}.tap do |parameter|
|
|
25
|
+
if (description = field.schema.description).present?
|
|
26
|
+
parameter["description"] = description
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def request_body(request)
|
|
32
|
+
content = request.body_media_types.each_with_object(ActiveSupport::OrderedHash.new) do |(media_type, fields), hash|
|
|
33
|
+
hash[MEDIA_TYPE_MAP.fetch(media_type, media_type.to_s)] = {
|
|
34
|
+
"schema" => object_schema(fields.fields)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
return if content.empty?
|
|
38
|
+
|
|
39
|
+
{ "content" => content }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def schema_for(schema)
|
|
43
|
+
case schema
|
|
44
|
+
when ActionSpec::Schema::Scalar then scalar_schema(schema)
|
|
45
|
+
when ActionSpec::Schema::ObjectOf then object_schema(schema.fields.values, schema:)
|
|
46
|
+
when ActionSpec::Schema::ArrayOf then array_schema(schema)
|
|
47
|
+
else { "type" => "string" }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parameter_name(field, location)
|
|
54
|
+
return field.name.to_s if location != :header
|
|
55
|
+
|
|
56
|
+
field.name.to_s.split("_").map(&:capitalize).join("-")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def scalar_schema(schema)
|
|
60
|
+
type = scalar_type(schema.type)
|
|
61
|
+
definition =
|
|
62
|
+
case type
|
|
63
|
+
when "string"
|
|
64
|
+
{ "type" => "string" }
|
|
65
|
+
when "integer"
|
|
66
|
+
{ "type" => "integer" }
|
|
67
|
+
when "number"
|
|
68
|
+
{ "type" => "number", "format" => number_format(schema.type) }.compact
|
|
69
|
+
when "boolean"
|
|
70
|
+
{ "type" => "boolean" }
|
|
71
|
+
when "file"
|
|
72
|
+
{ "type" => "string", "format" => "binary" }
|
|
73
|
+
when "object"
|
|
74
|
+
{ "type" => "object" }
|
|
75
|
+
else
|
|
76
|
+
{ "type" => "string" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
definition["format"] = string_format(schema.type) if string_format(schema.type)
|
|
80
|
+
apply_common_options(definition, schema)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def object_schema(fields, schema: nil)
|
|
84
|
+
definition = {
|
|
85
|
+
"type" => "object",
|
|
86
|
+
"properties" => fields.each_with_object(ActiveSupport::OrderedHash.new) do |field, properties|
|
|
87
|
+
properties[field.name.to_s] = schema_for(field.schema)
|
|
88
|
+
end
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
required = fields.select(&:required?).map { |field| field.name.to_s }
|
|
92
|
+
definition["required"] = required if required.any?
|
|
93
|
+
|
|
94
|
+
schema ? apply_common_options(definition, schema) : definition
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def array_schema(schema)
|
|
98
|
+
definition = {
|
|
99
|
+
"type" => "array",
|
|
100
|
+
"items" => schema_for(schema.item)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
apply_common_options(definition, schema)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def apply_common_options(definition, schema)
|
|
107
|
+
definition["description"] = schema.description if schema.description.present?
|
|
108
|
+
definition["default"] = schema.default unless schema.default.respond_to?(:call) || schema.default.nil?
|
|
109
|
+
definition["enum"] = schema.enum if schema.enum.present?
|
|
110
|
+
definition["pattern"] = regex_source(schema.pattern) if schema.pattern.present?
|
|
111
|
+
definition["example"] = schema.example if schema.example.present?
|
|
112
|
+
definition["examples"] = schema.examples if schema.examples.present?
|
|
113
|
+
apply_range(definition, schema.range)
|
|
114
|
+
definition
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def apply_range(definition, range)
|
|
118
|
+
return if range.blank?
|
|
119
|
+
|
|
120
|
+
rules = range.symbolize_keys
|
|
121
|
+
definition["minimum"] = rules[:ge] if rules.key?(:ge)
|
|
122
|
+
definition["exclusiveMinimum"] = rules[:gt] if rules.key?(:gt)
|
|
123
|
+
definition["maximum"] = rules[:le] if rules.key?(:le)
|
|
124
|
+
definition["exclusiveMaximum"] = rules[:lt] if rules.key?(:lt)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def scalar_type(type)
|
|
128
|
+
case ActionSpec::Schema::TypeCaster.normalize(type)
|
|
129
|
+
when :string then "string"
|
|
130
|
+
when :integer then "integer"
|
|
131
|
+
when :float, :decimal then "number"
|
|
132
|
+
when :boolean then "boolean"
|
|
133
|
+
when :date, :datetime, :time then "string"
|
|
134
|
+
when :file then "file"
|
|
135
|
+
when :object then "object"
|
|
136
|
+
else "string"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def string_format(type)
|
|
141
|
+
case ActionSpec::Schema::TypeCaster.normalize(type)
|
|
142
|
+
when :date then "date"
|
|
143
|
+
when :datetime then "date-time"
|
|
144
|
+
when :time then "time"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def number_format(type)
|
|
149
|
+
case ActionSpec::Schema::TypeCaster.normalize(type)
|
|
150
|
+
when :float then "float"
|
|
151
|
+
when :decimal then "double"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def regex_source(pattern)
|
|
156
|
+
pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -24,7 +24,7 @@ module ActionSpec
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def copy
|
|
27
|
-
self.class.new(item.copy, default:, enum:, range:, pattern:, allow_nil:, allow_blank:)
|
|
27
|
+
self.class.new(item.copy, default:, enum:, range:, pattern:, allow_nil:, allow_blank:, desc: description, example:, examples:)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
module Schema
|
|
5
5
|
class Base
|
|
6
|
-
attr_reader :default, :enum, :range, :pattern, :allow_nil, :allow_blank
|
|
6
|
+
attr_reader :default, :enum, :range, :pattern, :allow_nil, :allow_blank, :description, :example, :examples
|
|
7
7
|
|
|
8
8
|
def initialize(options = {})
|
|
9
9
|
options = options.symbolize_keys
|
|
@@ -13,6 +13,9 @@ module ActionSpec
|
|
|
13
13
|
@pattern = options[:pattern]
|
|
14
14
|
@allow_nil = options[:allow_nil]
|
|
15
15
|
@allow_blank = options[:allow_blank]
|
|
16
|
+
@description = options[:desc] || options[:description]
|
|
17
|
+
@example = options[:example]
|
|
18
|
+
@examples = options[:examples]
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def materialize_missing(_context:, _coerce:, _result:, _path:)
|
|
@@ -34,7 +34,7 @@ module ActionSpec
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def copy
|
|
37
|
-
self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, allow_nil:, allow_blank:)
|
|
37
|
+
self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, allow_nil:, allow_blank:, desc: description, example:, examples:)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
private
|
|
@@ -23,7 +23,7 @@ module ActionSpec
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def copy
|
|
26
|
-
self.class.new(type, default:, enum:, range:, pattern:, allow_nil:, allow_blank:)
|
|
26
|
+
self.class.new(type, default:, enum:, range:, pattern:, allow_nil:, allow_blank:, desc: description, example:, examples:)
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
data/lib/action_spec/version.rb
CHANGED
data/lib/action_spec.rb
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
namespace :action_spec do
|
|
2
|
+
desc "Generate an OpenAPI 3.2 document from ActionSpec controller docs"
|
|
3
|
+
task gen: :environment do
|
|
4
|
+
config = ActionSpec.config
|
|
5
|
+
|
|
6
|
+
ActionSpec::OpenApi::Generator.generate!(
|
|
7
|
+
application: Rails.application,
|
|
8
|
+
output: Rails.root.join(ENV.fetch("OUTPUT", config.open_api_output)).to_s,
|
|
9
|
+
title: ENV["TITLE"].presence || config.open_api_title,
|
|
10
|
+
version: ENV["VERSION"].presence || config.open_api_version,
|
|
11
|
+
server_url: ENV["SERVER_URL"].presence || config.open_api_server_url
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
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: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- zhandao
|
|
@@ -62,6 +62,11 @@ files:
|
|
|
62
62
|
- lib/action_spec/doc/endpoint.rb
|
|
63
63
|
- lib/action_spec/header_hash.rb
|
|
64
64
|
- lib/action_spec/invalid_parameters.rb
|
|
65
|
+
- lib/action_spec/open_api.rb
|
|
66
|
+
- lib/action_spec/open_api/document.rb
|
|
67
|
+
- lib/action_spec/open_api/generator.rb
|
|
68
|
+
- lib/action_spec/open_api/operation.rb
|
|
69
|
+
- lib/action_spec/open_api/schema.rb
|
|
65
70
|
- lib/action_spec/railtie.rb
|
|
66
71
|
- lib/action_spec/schema.rb
|
|
67
72
|
- lib/action_spec/schema/array_of.rb
|