action_spec 0.1.0 → 1.0.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 +193 -62
- 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 +121 -0
- data/lib/action_spec/open_api/operation.rb +41 -0
- data/lib/action_spec/open_api/schema.rb +171 -0
- data/lib/action_spec/open_api.rb +8 -0
- data/lib/action_spec/railtie.rb +6 -0
- data/lib/action_spec/schema/active_record.rb +155 -0
- data/lib/action_spec/schema/array_of.rb +1 -1
- data/lib/action_spec/schema/base.rb +5 -1
- data/lib/action_spec/schema/object_of.rb +1 -1
- data/lib/action_spec/schema/scalar.rb +1 -1
- data/lib/action_spec/schema.rb +2 -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 +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6fccbffa8f14fb73bec7eade75add8f70d0371741999475ef292ed980d985ddc
|
|
4
|
+
data.tar.gz: 20f1b1d3b86826b1cb9d2ed3d72f0259ccd28c397790bbf8bc8fb8766cab0329
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 552c83ccebe73754cb2b6e160cbfb8774ea818e22e665a1ba6df322c5051e5ff7902b533d82c2c2a93f94fd0f631264e2e344521b67a3582576a27b5f4ce2144
|
|
7
|
+
data.tar.gz: 9585aa85edeb6654d00e06b7a0e2478aed741675d3d90e0b60c46ac99d267e68d13bbe30060f6b163b1869ac508b92c4509b8b0b0f69f3ae7d0c386da875afce
|
data/README.md
CHANGED
|
@@ -6,21 +6,31 @@ 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
|
|
10
|
-
|
|
11
|
-
##
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
10
|
+
|
|
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
|
+
- [Declare A Required Field](#declare-a-required-field)
|
|
20
|
+
- [Field Types](#field-types)
|
|
21
|
+
- [Field Options](#field-options)
|
|
22
|
+
- [Schemas From ActiveRecord](#schemas-from-activerecord)
|
|
23
|
+
- [Type And Boundary Matrix](#type-and-boundary-matrix)
|
|
24
|
+
- [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
|
|
25
|
+
- [Validation Flow](#validation-flow)
|
|
26
|
+
- [Reading Validated Values With `px`](#reading-validated-values-with-px)
|
|
27
|
+
- [Errors](#errors)
|
|
28
|
+
- [Default Rescue Behavior](#default-rescue-behavior)
|
|
29
|
+
- [Configuration And I18n](#configuration-and-i18n)
|
|
30
|
+
- [Configuration](#configuration)
|
|
31
|
+
- [I18n](#i18n)
|
|
32
|
+
|
|
33
|
+
## Example
|
|
24
34
|
|
|
25
35
|
```ruby
|
|
26
36
|
class UsersController < ApplicationController
|
|
@@ -32,7 +42,7 @@ class UsersController < ApplicationController
|
|
|
32
42
|
query :locale, String, default: "zh-CN"
|
|
33
43
|
query :page, Integer, default: -> { 1 }
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
form data: {
|
|
36
46
|
name!: String,
|
|
37
47
|
age: Integer,
|
|
38
48
|
birthday: Date,
|
|
@@ -69,25 +79,58 @@ Then run:
|
|
|
69
79
|
$ bundle
|
|
70
80
|
```
|
|
71
81
|
|
|
72
|
-
##
|
|
82
|
+
## OpenAPI Generation
|
|
83
|
+
|
|
84
|
+
Generate an OpenAPI document from the current Rails routes and ActionSpec controller docs:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
bin/rails action_spec:gen
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
By default, this writes to:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
docs/openapi.yml
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
For one-off runs, environment variables can override the default output path and document metadata:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bin/rails action_spec:gen \
|
|
100
|
+
OUTPUT=docs/openapi.yml \
|
|
101
|
+
TITLE="My API" \
|
|
102
|
+
VERSION="2026.03" \
|
|
103
|
+
SERVER_URL="https://api.example.com"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Notes:
|
|
107
|
+
|
|
108
|
+
- only routed controller actions with a matching `doc` declaration are included
|
|
109
|
+
- Rails paths such as `/users/:id(.:format)` are rendered as `/users/{id}`
|
|
110
|
+
- parameters, request bodies, and response descriptions are generated from the current DSL support
|
|
111
|
+
- if config and environment variables do not provide `TITLE` or `VERSION`, ActionSpec falls back to application-derived defaults
|
|
112
|
+
|
|
113
|
+
## Doc DSL
|
|
73
114
|
|
|
74
|
-
###
|
|
115
|
+
### `doc`
|
|
75
116
|
|
|
76
|
-
|
|
117
|
+
With action inferred from the next instance method:
|
|
77
118
|
|
|
78
119
|
```ruby
|
|
79
120
|
doc {
|
|
80
|
-
|
|
121
|
+
form data: { # <= request body DSL
|
|
122
|
+
name!: String # <= schema DSL
|
|
123
|
+
}
|
|
81
124
|
}
|
|
82
125
|
def create
|
|
83
126
|
end
|
|
84
127
|
```
|
|
85
128
|
|
|
86
|
-
|
|
129
|
+
Provide a summary:
|
|
87
130
|
|
|
88
131
|
```ruby
|
|
89
132
|
doc("Create user") {
|
|
90
|
-
|
|
133
|
+
form data: { name!: String }
|
|
91
134
|
}
|
|
92
135
|
def create
|
|
93
136
|
end
|
|
@@ -97,13 +140,13 @@ You can also bind it explicitly when you want the action name declared in place:
|
|
|
97
140
|
|
|
98
141
|
```ruby
|
|
99
142
|
doc(:create, "Create user") {
|
|
100
|
-
|
|
143
|
+
form data: { name!: String }
|
|
101
144
|
}
|
|
102
145
|
def create
|
|
103
146
|
end
|
|
104
147
|
```
|
|
105
148
|
|
|
106
|
-
###
|
|
149
|
+
### `doc_dry`
|
|
107
150
|
|
|
108
151
|
```ruby
|
|
109
152
|
class ApplicationController < ActionController::API
|
|
@@ -122,11 +165,7 @@ All matching dry blocks are applied before the action-specific `doc`.
|
|
|
122
165
|
|
|
123
166
|
### DSL Reference
|
|
124
167
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
#### Parameter Locations
|
|
128
|
-
|
|
129
|
-
Single-parameter forms:
|
|
168
|
+
#### Parameter
|
|
130
169
|
|
|
131
170
|
```ruby
|
|
132
171
|
header :Authorization, String
|
|
@@ -171,7 +210,7 @@ in_query!(
|
|
|
171
210
|
)
|
|
172
211
|
```
|
|
173
212
|
|
|
174
|
-
####
|
|
213
|
+
#### request body
|
|
175
214
|
|
|
176
215
|
General form:
|
|
177
216
|
|
|
@@ -199,7 +238,7 @@ data :file, File
|
|
|
199
238
|
|
|
200
239
|
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
240
|
|
|
202
|
-
#### Response
|
|
241
|
+
#### Response
|
|
203
242
|
|
|
204
243
|
```ruby
|
|
205
244
|
response 200, desc: "success"
|
|
@@ -210,9 +249,9 @@ error 401, "unauthorized"
|
|
|
210
249
|
|
|
211
250
|
Response declarations are stored as metadata now. They are not yet used to render responses automatically.
|
|
212
251
|
|
|
213
|
-
|
|
252
|
+
## Schemas
|
|
214
253
|
|
|
215
|
-
#### Required
|
|
254
|
+
#### Declare A Required Field
|
|
216
255
|
|
|
217
256
|
Use `!` in either place:
|
|
218
257
|
|
|
@@ -233,7 +272,7 @@ Meaning of `!`:
|
|
|
233
272
|
- keys such as `name!:` or `nickname!:` mark nested object fields as required
|
|
234
273
|
- `body!`, `json!`, and `form!` are currently accepted for DSL consistency, but today they behave the same as the non-bang form at runtime
|
|
235
274
|
|
|
236
|
-
####
|
|
275
|
+
#### Field Types
|
|
237
276
|
|
|
238
277
|
Scalar types currently supported by validation/coercion:
|
|
239
278
|
|
|
@@ -241,8 +280,7 @@ Scalar types currently supported by validation/coercion:
|
|
|
241
280
|
- `Integer`
|
|
242
281
|
- `Float`
|
|
243
282
|
- `BigDecimal`
|
|
244
|
-
- `:boolean`
|
|
245
|
-
- host-defined `Boolean` constant, if the host app already defines one
|
|
283
|
+
- `:boolean` / `Boolean`
|
|
246
284
|
- `Date`
|
|
247
285
|
- `DateTime`
|
|
248
286
|
- `Time`
|
|
@@ -257,28 +295,12 @@ json data: {
|
|
|
257
295
|
profile: {
|
|
258
296
|
nickname!: String
|
|
259
297
|
},
|
|
260
|
-
settings: { type: Object }
|
|
298
|
+
settings: { type: Object },
|
|
299
|
+
users: [{ id: Integer }]
|
|
261
300
|
}
|
|
262
301
|
```
|
|
263
302
|
|
|
264
|
-
####
|
|
265
|
-
|
|
266
|
-
| Type | Accepted examples | Rejected examples / notes |
|
|
267
|
-
| --- | --- | --- |
|
|
268
|
-
| `String` | `12`, `true`, `""` | Follows `ActiveModel::Type::String`, so `true` becomes `"t"` |
|
|
269
|
-
| `Integer` | `"0"`, `"-12"`, `"+7"`, `12` | Rejects `"12.3"`, `"abc"`, `""` |
|
|
270
|
-
| `Float` | `"0"`, `"-12.5"`, `12`, `12.5` | Rejects `"12.3.4"`, `"abc"` |
|
|
271
|
-
| `BigDecimal` | `"0"`, `"-12.50"`, `12`, `12.5` | Rejects `"abc"` |
|
|
272
|
-
| `:boolean` / host-defined `Boolean` | `true`, `false`, `"1"`, `"0"`, `"true"`, `"false"`, `"yes"`, `"no"`, `"on"`, `"off"` | Rejects ambiguous values such as `""`, `"2"`, `"TRUE "`, `"maybe"` |
|
|
273
|
-
| `Date` | `"2025-10-17"` | Rejects invalid dates such as `"2025-02-30"` |
|
|
274
|
-
| `DateTime` | `"2025-10-17T12:30:00Z"` | Rejects invalid datetimes such as `"2025-10-17 25:00:00"` |
|
|
275
|
-
| `Time` | `"2025-10-17T12:30:00Z"` | Follows `ActiveModel::Type::Time`, so the date part becomes `2000-01-01` |
|
|
276
|
-
| `File` | `ActionDispatch::Http::UploadedFile`, `Tempfile`, file-like IO objects | Keeps the object as-is and does not read file contents into memory |
|
|
277
|
-
| `Object` | `Hash`, `ActionController::Parameters`, arbitrary Ruby objects | Passed through for scalar `Object`; nested hashes use object schema resolution |
|
|
278
|
-
| `[Type]` | arrays such as `%w[1 2 3]` for `[Integer]` | Rejects non-array values, and reports item errors like `tags.1` |
|
|
279
|
-
| nested object | `{ profile: { nickname: "neo" } }` | Rejects non-hash values, and reports nested paths like `profile.nickname` |
|
|
280
|
-
|
|
281
|
-
#### Supported Runtime Options
|
|
303
|
+
#### Field Options
|
|
282
304
|
|
|
283
305
|
These options are currently used by the validator:
|
|
284
306
|
|
|
@@ -290,14 +312,87 @@ query :score, Integer, range: { ge: 1, le: 5 }
|
|
|
290
312
|
query :slug, String, pattern: /\A[a-z\-]+\z/
|
|
291
313
|
```
|
|
292
314
|
|
|
293
|
-
These options are currently
|
|
315
|
+
These options are currently used by OpenAPI generation, but are not yet used by the runtime validator:
|
|
294
316
|
|
|
295
317
|
- `desc`
|
|
296
318
|
- `example`
|
|
297
319
|
- `examples`
|
|
320
|
+
|
|
321
|
+
These options are not yet used by either the runtime validator or OpenAPI generation:
|
|
322
|
+
|
|
298
323
|
- `allow_nil`
|
|
299
324
|
- `allow_blank`
|
|
300
325
|
|
|
326
|
+
#### Schemas From ActiveRecord
|
|
327
|
+
|
|
328
|
+
If your model is an `ActiveRecord::Base`, you can derive an ActionSpec-friendly schema hash directly from the model:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
class UsersController < ApplicationController
|
|
332
|
+
doc {
|
|
333
|
+
form data: User.schemas
|
|
334
|
+
}
|
|
335
|
+
def create
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
`User.schemas` returns a hash that can be passed directly into `form data:`, `json data:`, or `body`.
|
|
341
|
+
|
|
342
|
+
By default it includes all model fields:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
User.schemas
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
You can also limit the exported fields:
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
User.schemas(only: %i[name phone role])
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel when available, including:
|
|
355
|
+
|
|
356
|
+
- field type
|
|
357
|
+
- requiredness, rendered as bang keys such as `"name!"`
|
|
358
|
+
- enum values from `enum`
|
|
359
|
+
- `default`
|
|
360
|
+
- `desc` from column comments
|
|
361
|
+
- `pattern` from format validators
|
|
362
|
+
- `range` from numericality validators
|
|
363
|
+
- `length` from length validators and string column limits
|
|
364
|
+
|
|
365
|
+
Example output:
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
User.schemas
|
|
369
|
+
# {
|
|
370
|
+
# "name!" => { type: String, desc: "user name", length: { maximum: 20 } },
|
|
371
|
+
# "phone!" => { type: String, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
|
|
372
|
+
# "role" => { type: String, enum: %w[admin member visitor] }
|
|
373
|
+
# }
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
#### Type And Boundary Matrix
|
|
377
|
+
|
|
378
|
+
| Type | Accepted examples | Rejected examples / notes |
|
|
379
|
+
| --- | --- | --- |
|
|
380
|
+
| `String` | `12`, `true`, `""` | Follows `ActiveModel::Type::String`, so `true` becomes `"t"` |
|
|
381
|
+
| `Integer` | `"0"`, `"-12"`, `"+7"`, `12` | Rejects `"12.3"`, `"abc"`, `""` |
|
|
382
|
+
| `Float` | `"0"`, `"-12.5"`, `12`, `12.5` | Rejects `"12.3.4"`, `"abc"` |
|
|
383
|
+
| `BigDecimal` | `"0"`, `"-12.50"`, `12`, `12.5` | Rejects `"abc"` |
|
|
384
|
+
| `:boolean` / `Boolean` | `true`, `false`, `"1"`, `"0"`, `"true"`, `"false"`, `"yes"`, `"no"`, `"on"`, `"off"` | Rejects ambiguous values such as `""`, `"2"`, `"TRUE "`, `"maybe"` |
|
|
385
|
+
| `Date` | `"2025-10-17"` | Rejects invalid dates such as `"2025-02-30"` |
|
|
386
|
+
| `DateTime` | `"2025-10-17T12:30:00Z"` | Rejects invalid datetimes such as `"2025-10-17 25:00:00"` |
|
|
387
|
+
| `Time` | `"2025-10-17T12:30:00Z"` | Follows `ActiveModel::Type::Time`, so the date part becomes `2000-01-01` |
|
|
388
|
+
| `File` | `ActionDispatch::Http::UploadedFile`, `Tempfile`, file-like IO objects | Keeps the object as-is and does not read file contents into memory |
|
|
389
|
+
| `Object` | `Hash`, `ActionController::Parameters`, arbitrary Ruby objects | Passed through for scalar `Object`; nested hashes use object schema resolution |
|
|
390
|
+
| `[Type]` | arrays such as `%w[1 2 3]` for `[Integer]` | Rejects non-array values, and reports item errors like `tags.1` |
|
|
391
|
+
| nested object | `{ profile: { nickname: "neo" } }` | Rejects non-hash values, and reports nested paths like `profile.nickname` |
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
## Parameter Validation And Type Coercion
|
|
395
|
+
|
|
301
396
|
### Validation Flow
|
|
302
397
|
|
|
303
398
|
#### `validate_params!`
|
|
@@ -396,12 +491,18 @@ The default JSON response is:
|
|
|
396
491
|
}
|
|
397
492
|
```
|
|
398
493
|
|
|
494
|
+
## Configuration And I18n
|
|
495
|
+
|
|
399
496
|
### Configuration
|
|
400
497
|
|
|
401
498
|
```ruby
|
|
402
499
|
ActionSpec.configure do |config|
|
|
403
500
|
config.rescue_invalid_parameters = true
|
|
404
501
|
config.invalid_parameters_status = :bad_request
|
|
502
|
+
config.open_api_output = "docs/openapi.yml"
|
|
503
|
+
config.open_api_title = "My API"
|
|
504
|
+
config.open_api_version = "2026.03"
|
|
505
|
+
config.open_api_server_url = "https://api.example.com"
|
|
405
506
|
|
|
406
507
|
config.error_messages[:invalid_type] = ->(_attribute, options) do
|
|
407
508
|
"should be coercible to #{options.fetch(:expected)}"
|
|
@@ -438,6 +539,22 @@ Available config keys:
|
|
|
438
539
|
Default: `{}`.
|
|
439
540
|
Lets you override error messages by error type, or by attribute plus error type.
|
|
440
541
|
|
|
542
|
+
- `open_api_output`
|
|
543
|
+
Default: `"docs/openapi.yml"`.
|
|
544
|
+
Controls where `bin/rails action_spec:gen` writes the generated OpenAPI document.
|
|
545
|
+
|
|
546
|
+
- `open_api_title`
|
|
547
|
+
Default: `nil`.
|
|
548
|
+
Sets the default OpenAPI `info.title` used by `bin/rails action_spec:gen`.
|
|
549
|
+
|
|
550
|
+
- `open_api_version`
|
|
551
|
+
Default: `nil`.
|
|
552
|
+
Sets the default OpenAPI `info.version` used by `bin/rails action_spec:gen`.
|
|
553
|
+
|
|
554
|
+
- `open_api_server_url`
|
|
555
|
+
Default: `nil`.
|
|
556
|
+
Sets the default server URL emitted in the generated OpenAPI document.
|
|
557
|
+
|
|
441
558
|
### I18n
|
|
442
559
|
|
|
443
560
|
ActionSpec loads its own locale files and uses `ActiveModel::Errors`, so you can override both messages and attribute names:
|
|
@@ -466,12 +583,26 @@ ActionSpec.configure do |config|
|
|
|
466
583
|
end
|
|
467
584
|
```
|
|
468
585
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
-
|
|
472
|
-
-
|
|
473
|
-
-
|
|
474
|
-
-
|
|
586
|
+
## What Is Not Implemented Yet
|
|
587
|
+
|
|
588
|
+
- reusable `components` generation
|
|
589
|
+
- `$ref` generation and deduplication
|
|
590
|
+
- `description`, `operationId`, `tags`, `externalDocs`, `deprecated`, and `security` on operations
|
|
591
|
+
- parameter-level `style`, `explode`, `allowReserved`, `examples`, and richer header/cookie serialization controls
|
|
592
|
+
- request body `encoding`
|
|
593
|
+
- multiple request/response media types beyond the current direct DSL mapping
|
|
594
|
+
- response body schema generation; current `response` / `resp` / `error` declarations only generate response descriptions
|
|
595
|
+
- response headers
|
|
596
|
+
- response links
|
|
597
|
+
- callbacks
|
|
598
|
+
- webhooks
|
|
599
|
+
- path-level shared parameters
|
|
600
|
+
- top-level `components.parameters`, `components.requestBodies`, `components.responses`, `components.headers`, `components.examples`, `components.links`, `components.callbacks`, `components.schemas`, `components.securitySchemes`, and `components.pathItems`
|
|
601
|
+
- top-level `security`
|
|
602
|
+
- top-level `tags`
|
|
603
|
+
- top-level `externalDocs`
|
|
604
|
+
- `jsonSchemaDialect`
|
|
605
|
+
- richer schema keywords beyond the current subset, including nullable/blank semantics, object-level constraints, and composition keywords such as `oneOf`, `anyOf`, `allOf`, and `not`
|
|
475
606
|
|
|
476
607
|
## Contributing
|
|
477
608
|
.
|
|
@@ -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,121 @@
|
|
|
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
|
+
route_verbs(route).each do |verb|
|
|
57
|
+
hash[path][verb] = Operation.new(endpoint).build
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def route_definitions
|
|
63
|
+
return routes if routes
|
|
64
|
+
|
|
65
|
+
application.routes.routes
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def controller_for(route)
|
|
69
|
+
controller_name = route_defaults(route)[:controller].presence
|
|
70
|
+
return unless controller_name
|
|
71
|
+
|
|
72
|
+
"#{controller_name.camelize}Controller".safe_constantize
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def route_action(route)
|
|
76
|
+
route_defaults(route).fetch(:action).to_sym
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def route_defaults(route)
|
|
80
|
+
defaults =
|
|
81
|
+
if route.respond_to?(:defaults)
|
|
82
|
+
route.defaults
|
|
83
|
+
elsif route.respond_to?(:requirements)
|
|
84
|
+
route.requirements
|
|
85
|
+
else
|
|
86
|
+
route[:defaults]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
defaults.to_h.symbolize_keys
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def route_verbs(route)
|
|
93
|
+
raw_verb =
|
|
94
|
+
if route.respond_to?(:verb) && route.verb.respond_to?(:source)
|
|
95
|
+
route.verb.source
|
|
96
|
+
elsif route.respond_to?(:verb)
|
|
97
|
+
route.verb.to_s
|
|
98
|
+
else
|
|
99
|
+
route[:verb].to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raw_verb.gsub(/[$^]/, "").split("|").filter_map { |verb| verb.presence&.downcase }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalized_path(route)
|
|
106
|
+
raw_path =
|
|
107
|
+
if route.respond_to?(:path) && route.path.respond_to?(:spec)
|
|
108
|
+
route.path.spec.to_s
|
|
109
|
+
elsif route.respond_to?(:path)
|
|
110
|
+
route.path.to_s
|
|
111
|
+
else
|
|
112
|
+
route[:path].to_s
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
raw_path
|
|
116
|
+
.sub(/\(\.:format\)\z/, "")
|
|
117
|
+
.gsub(/:(\w+)/, '{\1}')
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
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,171 @@
|
|
|
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
|
+
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?
|
|
114
|
+
apply_range(definition, schema.range)
|
|
115
|
+
definition
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def apply_range(definition, range)
|
|
119
|
+
return if range.blank?
|
|
120
|
+
|
|
121
|
+
rules = range.symbolize_keys
|
|
122
|
+
definition["minimum"] = rules[:ge] if rules.key?(:ge)
|
|
123
|
+
definition["exclusiveMinimum"] = rules[:gt] if rules.key?(:gt)
|
|
124
|
+
definition["maximum"] = rules[:le] if rules.key?(:le)
|
|
125
|
+
definition["exclusiveMaximum"] = rules[:lt] if rules.key?(:lt)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def apply_length(definition, length, type)
|
|
129
|
+
return if length.blank?
|
|
130
|
+
|
|
131
|
+
rules = length.symbolize_keys
|
|
132
|
+
return unless type == "string"
|
|
133
|
+
|
|
134
|
+
definition["minLength"] = rules[:minimum] if rules.key?(:minimum)
|
|
135
|
+
definition["maxLength"] = rules[:maximum] if rules.key?(:maximum)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def scalar_type(type)
|
|
139
|
+
case ActionSpec::Schema::TypeCaster.normalize(type)
|
|
140
|
+
when :string then "string"
|
|
141
|
+
when :integer then "integer"
|
|
142
|
+
when :float, :decimal then "number"
|
|
143
|
+
when :boolean then "boolean"
|
|
144
|
+
when :date, :datetime, :time then "string"
|
|
145
|
+
when :file then "file"
|
|
146
|
+
when :object then "object"
|
|
147
|
+
else "string"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def string_format(type)
|
|
152
|
+
case ActionSpec::Schema::TypeCaster.normalize(type)
|
|
153
|
+
when :date then "date"
|
|
154
|
+
when :datetime then "date-time"
|
|
155
|
+
when :time then "time"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def number_format(type)
|
|
160
|
+
case ActionSpec::Schema::TypeCaster.normalize(type)
|
|
161
|
+
when :float then "float"
|
|
162
|
+
when :decimal then "double"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def regex_source(pattern)
|
|
167
|
+
pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
data/lib/action_spec/railtie.rb
CHANGED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Schema
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def schemas(only: nil)
|
|
10
|
+
names = selected_column_names(only)
|
|
11
|
+
@action_spec_validator_index = build_validator_index
|
|
12
|
+
|
|
13
|
+
names.each_with_object(ActiveSupport::OrderedHash.new) do |name, hash|
|
|
14
|
+
hash[output_name(name)] = schema_definition_for(name)
|
|
15
|
+
end
|
|
16
|
+
ensure
|
|
17
|
+
remove_instance_variable(:@action_spec_validator_index) if instance_variable_defined?(:@action_spec_validator_index)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def selected_column_names(only)
|
|
23
|
+
selected = Array(only).presence&.map { |name| normalize_name(name) } || column_names
|
|
24
|
+
|
|
25
|
+
column_names.select { |name| selected.include?(name) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def output_name(name)
|
|
29
|
+
required_attribute?(name) ? "#{name}!" : name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def schema_definition_for(name)
|
|
33
|
+
definition = { type: schema_type_for(name) }
|
|
34
|
+
definition[:default] = column_default_for(name) unless column_default_for(name).nil?
|
|
35
|
+
definition[:desc] = column_comment_for(name) if column_comment_for(name).present?
|
|
36
|
+
definition[:enum] = resolved_enum_for(name) if resolved_enum_for(name).present?
|
|
37
|
+
definition[:pattern] = pattern_for(name) if pattern_for(name)
|
|
38
|
+
definition[:range] = range_for(name) if range_for(name).present?
|
|
39
|
+
definition[:length] = length_for(name) if length_for(name).present?
|
|
40
|
+
definition
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def schema_type_for(name)
|
|
44
|
+
return String if enum_values_for(name).present?
|
|
45
|
+
|
|
46
|
+
case columns_hash.fetch(name).type
|
|
47
|
+
when :string, :text, :binary then String
|
|
48
|
+
when :integer, :bigint then Integer
|
|
49
|
+
when :float then Float
|
|
50
|
+
when :decimal then BigDecimal
|
|
51
|
+
when :boolean then :boolean
|
|
52
|
+
when :date then Date
|
|
53
|
+
when :datetime, :timestamp then DateTime
|
|
54
|
+
when :time then Time
|
|
55
|
+
when :json, :jsonb then Object
|
|
56
|
+
else String
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def required_attribute?(name)
|
|
61
|
+
(!column_nullable?(name) && column_default_for(name).nil?) || presence_validated?(name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def column_nullable?(name)
|
|
65
|
+
columns_hash.fetch(name).null
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def column_default_for(name)
|
|
69
|
+
columns_hash.fetch(name).default
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def column_comment_for(name)
|
|
73
|
+
columns_hash.fetch(name).comment
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def enum_values_for(name)
|
|
77
|
+
defined_enums.fetch(name.to_s, nil)&.keys
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def inclusion_values_for(name)
|
|
81
|
+
validator_for(name, ActiveModel::Validations::InclusionValidator)&.options&.fetch(:in, nil)&.to_a
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolved_enum_for(name)
|
|
85
|
+
enum_values_for(name).presence || inclusion_values_for(name).presence
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def pattern_for(name)
|
|
89
|
+
validator_for(name, ActiveModel::Validations::FormatValidator)&.options&.fetch(:with, nil)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def range_for(name)
|
|
93
|
+
options = validator_for(name, ActiveModel::Validations::NumericalityValidator)&.options
|
|
94
|
+
return if options.blank?
|
|
95
|
+
|
|
96
|
+
{}.tap do |range|
|
|
97
|
+
range[:gt] = options[:greater_than] if options.key?(:greater_than)
|
|
98
|
+
range[:ge] = options[:greater_than_or_equal_to] if options.key?(:greater_than_or_equal_to)
|
|
99
|
+
range[:lt] = options[:less_than] if options.key?(:less_than)
|
|
100
|
+
range[:le] = options[:less_than_or_equal_to] if options.key?(:less_than_or_equal_to)
|
|
101
|
+
end.presence
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def length_for(name)
|
|
105
|
+
definition = {}.tap do |length|
|
|
106
|
+
limit = string_limit_for(name)
|
|
107
|
+
length[:maximum] = limit if limit
|
|
108
|
+
|
|
109
|
+
options = validator_for(name, ActiveModel::Validations::LengthValidator)&.options || {}
|
|
110
|
+
length[:minimum] = options[:minimum] if options.key?(:minimum)
|
|
111
|
+
length[:maximum] = options[:maximum] if options.key?(:maximum)
|
|
112
|
+
|
|
113
|
+
if options.key?(:is)
|
|
114
|
+
length[:minimum] = options[:is]
|
|
115
|
+
length[:maximum] = options[:is]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
definition.presence
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def string_limit_for(name)
|
|
123
|
+
column = columns_hash.fetch(name)
|
|
124
|
+
return unless column.type.in?([:string, :text])
|
|
125
|
+
|
|
126
|
+
column.limit
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def presence_validated?(name)
|
|
130
|
+
validator_for(name, ActiveModel::Validations::PresenceValidator).present?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def validator_for(name, klass)
|
|
134
|
+
validator_index.fetch(name.to_s, []).find { |validator| validator.is_a?(klass) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def normalize_name(name)
|
|
138
|
+
name.to_s.delete_suffix("!")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def build_validator_index
|
|
142
|
+
validators.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |validator, index|
|
|
143
|
+
validator.attributes.each do |attribute|
|
|
144
|
+
index[attribute.to_s] << validator
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validator_index
|
|
150
|
+
@action_spec_validator_index ||= build_validator_index
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
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:, length:, 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, :length, :allow_nil, :allow_blank, :description, :example, :examples
|
|
7
7
|
|
|
8
8
|
def initialize(options = {})
|
|
9
9
|
options = options.symbolize_keys
|
|
@@ -11,8 +11,12 @@ module ActionSpec
|
|
|
11
11
|
@enum = options[:enum]
|
|
12
12
|
@range = options[:range]
|
|
13
13
|
@pattern = options[:pattern]
|
|
14
|
+
@length = options[:length]
|
|
14
15
|
@allow_nil = options[:allow_nil]
|
|
15
16
|
@allow_blank = options[:allow_blank]
|
|
17
|
+
@description = options[:desc] || options[:description]
|
|
18
|
+
@example = options[:example]
|
|
19
|
+
@examples = options[:examples]
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
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:, length:, 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:, length:, allow_nil:, allow_blank:, desc: description, example:, examples:)
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
data/lib/action_spec/schema.rb
CHANGED
|
@@ -5,13 +5,14 @@ require "action_spec/schema/field"
|
|
|
5
5
|
require "action_spec/schema/scalar"
|
|
6
6
|
require "action_spec/schema/object_of"
|
|
7
7
|
require "action_spec/schema/array_of"
|
|
8
|
+
require "action_spec/schema/active_record"
|
|
8
9
|
require "action_spec/schema/resolver"
|
|
9
10
|
require "action_spec/schema/type_caster"
|
|
10
11
|
|
|
11
12
|
module ActionSpec
|
|
12
13
|
module Schema
|
|
13
14
|
Missing = Object.new.freeze
|
|
14
|
-
OPTION_KEYS = %i[default desc enum range pattern allow_nil allow_blank example examples].freeze
|
|
15
|
+
OPTION_KEYS = %i[default desc enum range pattern length allow_nil allow_blank example examples].freeze
|
|
15
16
|
|
|
16
17
|
class << self
|
|
17
18
|
def build(type = nil, **options)
|
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:
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- zhandao
|
|
@@ -62,8 +62,14 @@ 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
|
|
72
|
+
- lib/action_spec/schema/active_record.rb
|
|
67
73
|
- lib/action_spec/schema/array_of.rb
|
|
68
74
|
- lib/action_spec/schema/base.rb
|
|
69
75
|
- lib/action_spec/schema/field.rb
|