action_spec 1.3.0 → 1.4.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 +112 -98
- data/lib/action_spec/configuration.rb +3 -1
- data/lib/action_spec/doc/dsl.rb +59 -31
- data/lib/action_spec/doc/endpoint.rb +87 -6
- data/lib/action_spec/open_api/operation.rb +1 -1
- data/lib/action_spec/open_api/schema.rb +106 -1
- data/lib/action_spec/schema/array_of.rb +1 -1
- data/lib/action_spec/schema/base.rb +9 -4
- data/lib/action_spec/schema/field.rb +44 -3
- data/lib/action_spec/schema/object_of.rb +2 -2
- data/lib/action_spec/schema/resolver.rb +27 -4
- data/lib/action_spec/schema/scalar.rb +2 -2
- data/lib/action_spec/schema/type_caster.rb +1 -1
- data/lib/action_spec/schema.rb +68 -11
- data/lib/action_spec/validator/runner.rb +17 -1
- data/lib/action_spec/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 432c84ac7303d7194f5617f2fe724bdb2d332662eb543a0545712731cac767dc
|
|
4
|
+
data.tar.gz: 90343cbda95d9a89452aabf86da6731606d2404a4c2bcd0a9358b48f705bd6cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 631a11eea99a092b2c104ff3934fcddd1777f8a2346468ef91170ff1bc5acea9844002b438b4ec668a5a20f7a97d58c69bdf8f99482b1b757e4ccf01fadf119c
|
|
7
|
+
data.tar.gz: 886a2975a48967efcf844c4577f05c2ebbb4cd8ed2d6c883e80c6830b1388f57b38b9f6e202376b01a71d1109ec75869d20ab143950d60cb5638e98e4b111c02
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# ActionSpec
|
|
1
|
+
# ActionSpec
|
|
2
2
|
|
|
3
|
-
Concise and Powerful API Documentation Solution for Rails.
|
|
3
|
+
Concise and Powerful API Documentation Solution for Rails. [中文](README_zh.md)
|
|
4
4
|
|
|
5
5
|
<img src=".github/assets/action_spec.jpg" />
|
|
6
6
|
|
|
@@ -10,27 +10,30 @@ Concise and Powerful API Documentation Solution for Rails.
|
|
|
10
10
|
|
|
11
11
|
## Table Of Contents
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
13
|
+
1. [OpenAPI Generation](#openapi-generation)
|
|
14
|
+
2. [Doc DSL](#doc-dsl)
|
|
15
|
+
1. [`doc`](#doc)
|
|
16
|
+
2. [`doc_dry`](#doc_dry)
|
|
17
|
+
3. [DSL Inside `doc`](#dsl-inside-doc)
|
|
18
|
+
1. [Parameter](#parameter)
|
|
19
|
+
2. [request body](#request-body)
|
|
20
|
+
3. [`openapi false`](#openapi-false)
|
|
21
|
+
4. [Scope](#scope)
|
|
22
|
+
5. [Response](#response)
|
|
23
|
+
3. [Schemas](#schemas)
|
|
24
|
+
1. [Declare A Required Field](#declare-a-required-field)
|
|
25
|
+
2. [Field Types](#field-types)
|
|
26
|
+
3. [Field Options](#field-options)
|
|
27
|
+
4. [Schemas From ActiveRecord](#schemas-from-activerecord)
|
|
28
|
+
5. [Type And Boundary Matrix](#type-and-boundary-matrix)
|
|
29
|
+
4. [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
|
|
30
|
+
1. [Validation Flow](#validation-flow)
|
|
31
|
+
2. [Reading Processed Values With `px`](#reading-processed-values-with-px)
|
|
32
|
+
3. [Errors](#errors)
|
|
33
|
+
5. [Configuration And I18n](#configuration-and-i18n)
|
|
34
|
+
1. [Configuration](#configuration)
|
|
35
|
+
2. [I18n](#i18n)
|
|
36
|
+
6. [AI Generation Style Guide](#ai-generation-style-guide)
|
|
34
37
|
|
|
35
38
|
## Example
|
|
36
39
|
|
|
@@ -59,10 +62,8 @@ class UsersController < ApplicationController
|
|
|
59
62
|
}
|
|
60
63
|
def create
|
|
61
64
|
User.create!(
|
|
62
|
-
account_id: px[:account_id],
|
|
63
|
-
|
|
64
|
-
birthday: px[:birthday],
|
|
65
|
-
admin: px[:admin]
|
|
65
|
+
account_id: px[:account_id], name: px[:name],
|
|
66
|
+
**px.slice(:birthday, :admin, :profile)
|
|
66
67
|
)
|
|
67
68
|
end
|
|
68
69
|
end
|
|
@@ -95,7 +96,7 @@ By default, this writes to:
|
|
|
95
96
|
docs/openapi.yml
|
|
96
97
|
```
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
Environment variables can override the default output path and document metadata:
|
|
99
100
|
|
|
100
101
|
```bash
|
|
101
102
|
bin/rails action_spec:gen \
|
|
@@ -149,6 +150,18 @@ def create
|
|
|
149
150
|
end
|
|
150
151
|
```
|
|
151
152
|
|
|
153
|
+
Override the default OpenAPI tag with `tag:`. By default, the tag comes from the routed `controller_path`:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
doc_dry(:index, tag: "backoffice")
|
|
157
|
+
|
|
158
|
+
doc("List users", tag: "members") {
|
|
159
|
+
query :status, String
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Generated OpenAPI operations also include an `operationId`, built from the final tag plus the action name, for example `members_index` or `users_create`.
|
|
164
|
+
|
|
152
165
|
### `doc_dry`
|
|
153
166
|
|
|
154
167
|
```ruby
|
|
@@ -166,31 +179,7 @@ end
|
|
|
166
179
|
|
|
167
180
|
All matching dry blocks are applied before the action-specific `doc`.
|
|
168
181
|
|
|
169
|
-
###
|
|
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
|
-
|
|
193
|
-
### DSL Reference
|
|
182
|
+
### DSL Inside `doc`
|
|
194
183
|
|
|
195
184
|
#### Parameter
|
|
196
185
|
|
|
@@ -208,7 +197,16 @@ cookie :remember_token, String
|
|
|
208
197
|
cookie! :remember_token, String
|
|
209
198
|
```
|
|
210
199
|
|
|
211
|
-
Bang methods mark the field as required. For example, `query! :page, Integer` means the request must include `page`.
|
|
200
|
+
Bang methods mark the field as required. For example, `query! :page, Integer` means the request must include `page`, and the value must not be `nil`. Blank values are still allowed unless you set `blank: false`.
|
|
201
|
+
|
|
202
|
+
If you prefer not to use bang methods, you can also write `required: true`:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
query :page, Integer, required: true
|
|
206
|
+
json data: {
|
|
207
|
+
title: { type: String, required: true }
|
|
208
|
+
}
|
|
209
|
+
```
|
|
212
210
|
|
|
213
211
|
Batch declaration forms:
|
|
214
212
|
|
|
@@ -249,11 +247,9 @@ Convenience helpers:
|
|
|
249
247
|
|
|
250
248
|
```ruby
|
|
251
249
|
json data: { name!: String }
|
|
252
|
-
|
|
253
250
|
json! data: { name!: String }
|
|
254
251
|
|
|
255
252
|
form data: { file!: File, position: String }
|
|
256
|
-
|
|
257
253
|
form! data: { file!: File }
|
|
258
254
|
```
|
|
259
255
|
|
|
@@ -263,16 +259,23 @@ Single multipart field helper:
|
|
|
263
259
|
data :file, File
|
|
264
260
|
```
|
|
265
261
|
|
|
266
|
-
|
|
262
|
+
Notes:
|
|
263
|
+
|
|
264
|
+
1. When multiple `body/body!`, `json/json!`, or `form/form!` declarations are used:
|
|
265
|
+
- declarations with the same media type are merged
|
|
266
|
+
- if multiple media types are declared, the generated OpenAPI document will emit multiple media types
|
|
267
|
+
- field validation and coercion do not distinguish between media types, and always read values from Rails `params`
|
|
267
268
|
|
|
268
|
-
|
|
269
|
+
`body!`, `json!`, and `form!` make the root request body required at runtime. You can also write `required: true` on `body`, `json`, or `form` if you prefer not to use bang methods.
|
|
270
|
+
|
|
271
|
+
#### `openapi false`
|
|
272
|
+
|
|
273
|
+
You can also opt an action out of OpenAPI generation from either `doc` or `doc_dry`:
|
|
269
274
|
|
|
270
275
|
```ruby
|
|
271
276
|
openapi false
|
|
272
277
|
```
|
|
273
278
|
|
|
274
|
-
Use this when an action should stay out of the generated OpenAPI document. It also works inside `doc_dry`.
|
|
275
|
-
|
|
276
279
|
#### Scope
|
|
277
280
|
|
|
278
281
|
Use `scope` when you want a grouped view that spans multiple request locations:
|
|
@@ -283,6 +286,7 @@ doc {
|
|
|
283
286
|
query :user_id, Integer
|
|
284
287
|
form data: { name: String }
|
|
285
288
|
}
|
|
289
|
+
form data: { not_in_scope: String }
|
|
286
290
|
}
|
|
287
291
|
```
|
|
288
292
|
|
|
@@ -297,11 +301,27 @@ px.scope[:user] # => { user_id: 1, name: "Tom" }
|
|
|
297
301
|
```ruby
|
|
298
302
|
response 200, desc: "success"
|
|
299
303
|
response 422, "validation failed"
|
|
300
|
-
|
|
304
|
+
response 200, :json, data: { code!: Integer, result: Object }
|
|
305
|
+
|
|
301
306
|
error 401, "unauthorized"
|
|
307
|
+
error 503, { code!: Integer, message!: String } # error data schema
|
|
308
|
+
error 503, { code: 1000, message: "invalid params" } # unnamed error example
|
|
309
|
+
error 503, invalid_params: { code: 1000, message: "invalid params" } # named error example
|
|
310
|
+
# declare multiple named examples in batch
|
|
311
|
+
errors 503, {
|
|
312
|
+
invalid_params: { code: 1000, message: "invalid params" },
|
|
313
|
+
network_error: { code: 1001, message: "network error" }
|
|
314
|
+
}
|
|
315
|
+
errors 503, network_error: { code: 1001 }, upstream_timeout: { code: 1002 } # braces are also optional
|
|
316
|
+
|
|
302
317
|
```
|
|
303
318
|
|
|
304
|
-
Response declarations are stored as metadata
|
|
319
|
+
Response declarations are stored as metadata and are emitted in OpenAPI. They do not render responses automatically at runtime.
|
|
320
|
+
|
|
321
|
+
Notes:
|
|
322
|
+
|
|
323
|
+
1. `response`, `error`, and `errors` default `media_type` to `:json` and this default is configurable.
|
|
324
|
+
2. If examples are declared without an explicit schema, ActionSpec infers the response schema from the example payloads for OpenAPI generation.
|
|
305
325
|
|
|
306
326
|
## Schemas
|
|
307
327
|
|
|
@@ -324,7 +344,11 @@ Meaning of `!`:
|
|
|
324
344
|
|
|
325
345
|
- `query!`, `path!`, `header!`, `cookie!` mark the parameter itself as required
|
|
326
346
|
- keys such as `name!:` or `nickname!:` mark nested object fields as required
|
|
327
|
-
- `body!`, `json!`, and `form!`
|
|
347
|
+
- `body!`, `json!`, and `form!` mark the root request body as required
|
|
348
|
+
|
|
349
|
+
You can also use `required: true` instead of bang syntax for parameters, nested fields, and the root request body.
|
|
350
|
+
|
|
351
|
+
`required` in ActionSpec means "present and not `nil`". It does not reject blank strings by itself. If you want to reject blank values, use `blank: false` or `allow_blank: false`.
|
|
328
352
|
|
|
329
353
|
#### Field Types
|
|
330
354
|
|
|
@@ -356,26 +380,31 @@ json data: {
|
|
|
356
380
|
|
|
357
381
|
#### Field Options
|
|
358
382
|
|
|
359
|
-
These options are currently used by the validator:
|
|
360
|
-
|
|
361
383
|
```ruby
|
|
362
384
|
query :page, Integer, default: 1
|
|
363
385
|
query :today, Date, default: -> { Time.current.to_date }
|
|
364
386
|
query :status, String, enum: %w[draft published]
|
|
365
387
|
query :score, Integer, range: { ge: 1, le: 5 }
|
|
366
388
|
query :slug, String, pattern: /\A[a-z\-]+\z/
|
|
389
|
+
query :title, String, blank: false # or allow_blank: false
|
|
390
|
+
|
|
391
|
+
query :nickname, String, transform: :downcase
|
|
392
|
+
query :page, Integer, transform: -> { it + 1 }, px: :page_number
|
|
393
|
+
query :request_id, String, px_key: :trace_id
|
|
367
394
|
```
|
|
368
395
|
|
|
369
|
-
|
|
396
|
+
Notes:
|
|
397
|
+
|
|
398
|
+
- `transform` accepts a `Symbol` or a `Proc` and runs **after coercion**, before the value is written into `px`
|
|
399
|
+
- `px` and `px_key` customize the key name written into `px`; `px` is the short form of `px_key`
|
|
370
400
|
|
|
371
|
-
|
|
372
|
-
- `example`
|
|
373
|
-
- `examples`
|
|
401
|
+
These options are used by OpenAPI generation:
|
|
374
402
|
|
|
375
|
-
|
|
403
|
+
```ruby
|
|
404
|
+
query :page, Integer, desc: "page number", example: 1, examples: [1, 2, 3]
|
|
405
|
+
```
|
|
376
406
|
|
|
377
|
-
- `
|
|
378
|
-
- `allow_blank`
|
|
407
|
+
If an OpenAPI-facing option such as `default` cannot be converted into YAML, for example `default: -> { ... }`, it will be omitted from the generated OpenAPI document.
|
|
379
408
|
|
|
380
409
|
#### Schemas From ActiveRecord
|
|
381
410
|
|
|
@@ -484,6 +513,7 @@ This hook also skips actions without a matching `doc`, so it is safe to declare
|
|
|
484
513
|
### Reading Processed Values With `px`
|
|
485
514
|
|
|
486
515
|
`px` stores the processed values produced by ActionSpec. With `validate_params!` they stay raw; with `validate_and_coerce_params!` they are coerced values.
|
|
516
|
+
|
|
487
517
|
Because `px` is still a hash, you can also use helpers such as `px.slice(...)` to simplify parameter access code.
|
|
488
518
|
|
|
489
519
|
```ruby
|
|
@@ -555,6 +585,7 @@ ActionSpec.configure { |config|
|
|
|
555
585
|
config.open_api_title = "My API"
|
|
556
586
|
config.open_api_version = "2026.03"
|
|
557
587
|
config.open_api_server_url = "https://api.example.com"
|
|
588
|
+
config.default_response_media_type = :json
|
|
558
589
|
|
|
559
590
|
config.error_messages[:invalid_type] = ->(_attribute, options) {
|
|
560
591
|
"should be coercible to #{options.fetch(:expected)}"
|
|
@@ -564,29 +595,13 @@ ActionSpec.configure { |config|
|
|
|
564
595
|
|
|
565
596
|
Available config keys:
|
|
566
597
|
|
|
567
|
-
- `invalid_parameters_exception_class`
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
- `
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
- `open_api_output`
|
|
576
|
-
Default: `"docs/openapi.yml"`.
|
|
577
|
-
Controls where `bin/rails action_spec:gen` writes the generated OpenAPI document.
|
|
578
|
-
|
|
579
|
-
- `open_api_title`
|
|
580
|
-
Default: `nil`.
|
|
581
|
-
Sets the default OpenAPI `info.title` used by `bin/rails action_spec:gen`.
|
|
582
|
-
|
|
583
|
-
- `open_api_version`
|
|
584
|
-
Default: `nil`.
|
|
585
|
-
Sets the default OpenAPI `info.version` used by `bin/rails action_spec:gen`.
|
|
586
|
-
|
|
587
|
-
- `open_api_server_url`
|
|
588
|
-
Default: `nil`.
|
|
589
|
-
Sets the default server URL emitted in the generated OpenAPI document.
|
|
598
|
+
- `invalid_parameters_exception_class`: Default `ActionSpec::InvalidParameters`; controls which exception class is raised when validation fails.
|
|
599
|
+
- `error_messages`: Default `{}`; lets you override error messages by error type, or by attribute plus error type.
|
|
600
|
+
- `open_api_output`: Default `"docs/openapi.yml"`; controls where `bin/rails action_spec:gen` writes the generated OpenAPI document.
|
|
601
|
+
- `open_api_title`: Default `nil`; sets the default OpenAPI `info.title` used by `bin/rails action_spec:gen`.
|
|
602
|
+
- `open_api_version`: Default `nil`; sets the default OpenAPI `info.version` used by `bin/rails action_spec:gen`.
|
|
603
|
+
- `open_api_server_url`: Default `nil`; sets the default server URL emitted in the generated OpenAPI document.
|
|
604
|
+
- `default_response_media_type`: Default `:json`; sets the default response media type used by `response`, `error`, and `errors` when no media type is passed explicitly.
|
|
590
605
|
|
|
591
606
|
### I18n
|
|
592
607
|
|
|
@@ -625,7 +640,7 @@ When using AI tools to generate Rails controller code, and the change involves p
|
|
|
625
640
|
- when a batch has 3 fields or fewer and does not contain nested hashes, prefer a single-line style, for example:
|
|
626
641
|
- `json data: { type: String, required: true }`
|
|
627
642
|
- `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
|
|
643
|
+
- use `doc_dry`, `scope`, and `transform`、`px(px_key)`、`px.slice` to keep controller concise
|
|
629
644
|
- when request parameters match model declarations, prefer `.schemas` to keep `doc` concise
|
|
630
645
|
|
|
631
646
|
## What Is Not Implemented Yet
|
|
@@ -636,7 +651,6 @@ When using AI tools to generate Rails controller code, and the change involves p
|
|
|
636
651
|
- parameter-level `style`, `explode`, `allowReserved`, `examples`, and richer header/cookie serialization controls
|
|
637
652
|
- request body `encoding`
|
|
638
653
|
- multiple request/response media types beyond the current direct DSL mapping
|
|
639
|
-
- response body schema generation; current `response` / `resp` / `error` declarations only generate response descriptions
|
|
640
654
|
- response headers
|
|
641
655
|
- response links
|
|
642
656
|
- callbacks
|
|
@@ -647,7 +661,7 @@ When using AI tools to generate Rails controller code, and the change involves p
|
|
|
647
661
|
- top-level `tags`
|
|
648
662
|
- top-level `externalDocs`
|
|
649
663
|
- `jsonSchemaDialect`
|
|
650
|
-
- richer schema keywords beyond the current subset, including
|
|
664
|
+
- richer schema keywords beyond the current subset, including object-level constraints, and composition keywords such as `oneOf`, `anyOf`, `allOf`, and `not`
|
|
651
665
|
|
|
652
666
|
## Contributing
|
|
653
667
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :invalid_parameters_exception_class, :open_api_output, :open_api_title, :open_api_version,
|
|
6
|
-
:open_api_server_url
|
|
6
|
+
:open_api_server_url, :default_response_media_type
|
|
7
7
|
attr_reader :error_messages
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
@@ -12,6 +12,7 @@ module ActionSpec
|
|
|
12
12
|
@open_api_title = nil
|
|
13
13
|
@open_api_version = nil
|
|
14
14
|
@open_api_server_url = nil
|
|
15
|
+
@default_response_media_type = :json
|
|
15
16
|
@error_messages = ActiveSupport::HashWithIndifferentAccess.new
|
|
16
17
|
end
|
|
17
18
|
|
|
@@ -33,6 +34,7 @@ module ActionSpec
|
|
|
33
34
|
copy.open_api_title = open_api_title
|
|
34
35
|
copy.open_api_version = open_api_version
|
|
35
36
|
copy.open_api_server_url = open_api_server_url
|
|
37
|
+
copy.default_response_media_type = default_response_media_type
|
|
36
38
|
copy.error_messages = error_messages.deep_dup
|
|
37
39
|
end
|
|
38
40
|
end
|
data/lib/action_spec/doc/dsl.rb
CHANGED
|
@@ -28,12 +28,12 @@ module ActionSpec
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def body(media_type, data: {}, **)
|
|
32
|
-
add_body(media_type, data)
|
|
31
|
+
def body(media_type, data: {}, required: false, **)
|
|
32
|
+
add_body(media_type, data, required:)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def body!(media_type, data: {}, **)
|
|
36
|
-
add_body(media_type, data)
|
|
36
|
+
add_body(media_type, data, required: true)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def json(data:, **options)
|
|
@@ -67,57 +67,85 @@ module ActionSpec
|
|
|
67
67
|
endpoint.options[:openapi] = enabled
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
RESPONSE_MEDIA_TYPES = %i[json form].freeze
|
|
71
|
+
|
|
72
|
+
def response(code, description = nil, media_type = nil, desc: nil, data: nil, example: nil, examples: nil, **options)
|
|
73
|
+
description, media_type = normalize_response_arguments(description, media_type)
|
|
74
|
+
schema, example, examples = normalize_response_body(description, data:, example:, examples:, options:)
|
|
71
75
|
endpoint.add_response(
|
|
72
76
|
code,
|
|
73
77
|
Response.new(
|
|
74
78
|
code:,
|
|
75
|
-
description: description
|
|
76
|
-
media_type
|
|
79
|
+
description: resolved_description(description, desc),
|
|
80
|
+
media_type: media_type || ActionSpec.config.default_response_media_type,
|
|
81
|
+
schema:,
|
|
82
|
+
example:,
|
|
83
|
+
examples:,
|
|
77
84
|
options:
|
|
78
85
|
)
|
|
79
86
|
)
|
|
80
87
|
end
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
def error(code, description = nil, media_type = nil, desc: nil, **options)
|
|
90
|
+
response(code, description, media_type, desc: desc || "Error", **options)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def errors(code, examples = nil, media_type = nil, desc: "Error", **options)
|
|
94
|
+
response(code, nil, media_type, desc:, examples: examples || options, **options.except(*Array((examples || options).keys)))
|
|
95
|
+
end
|
|
84
96
|
|
|
85
97
|
private
|
|
86
98
|
|
|
87
99
|
attr_reader :endpoint
|
|
88
100
|
attr_reader :scopes
|
|
89
101
|
|
|
102
|
+
def normalize_response_arguments(description, media_type)
|
|
103
|
+
return [nil, description] if media_type.nil? && response_media_type?(description)
|
|
104
|
+
|
|
105
|
+
[description, media_type]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def response_media_type?(value)
|
|
109
|
+
value.is_a?(Symbol) && RESPONSE_MEDIA_TYPES.include?(value)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normalize_response_body(description, data:, example:, examples:, options:)
|
|
113
|
+
return [data && ActionSpec::Schema.from_definition(data), example, examples] if data || example || examples
|
|
114
|
+
return [nil, nil, options.presence] if options.present?
|
|
115
|
+
return [nil, nil, nil] if description.is_a?(String) || description.nil?
|
|
116
|
+
|
|
117
|
+
parsed_schema = ActionSpec::Schema.schema_definition?(description) ? ActionSpec::Schema.from_definition(description) : nil
|
|
118
|
+
return [parsed_schema, nil, nil] if parsed_schema
|
|
119
|
+
|
|
120
|
+
[nil, description, options.presence]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resolved_description(description, desc)
|
|
124
|
+
return description if description.is_a?(String)
|
|
125
|
+
return desc if desc.present?
|
|
126
|
+
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
90
130
|
def add_param(location_name, name, type, required:, **options)
|
|
91
|
-
|
|
92
|
-
endpoint.request.add_param(
|
|
131
|
+
required ||= options.delete(:required) == true
|
|
132
|
+
endpoint.request.add_param(
|
|
133
|
+
location_name,
|
|
134
|
+
ActionSpec::Schema.build_field(name, options.merge(type:), required:, scopes: scopes.dup)
|
|
135
|
+
)
|
|
93
136
|
end
|
|
94
137
|
|
|
95
138
|
def add_many(location_name, params, required:)
|
|
96
139
|
params.each_pair do |name, definition|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
location_name,
|
|
102
|
-
ActionSpec::Schema::Field.new(
|
|
103
|
-
name:,
|
|
104
|
-
required:,
|
|
105
|
-
schema: ActionSpec::Schema.from_definition(definition),
|
|
106
|
-
scopes: scopes.dup
|
|
107
|
-
)
|
|
108
|
-
)
|
|
109
|
-
else
|
|
110
|
-
add_param(location_name, name, String, required:, **definition)
|
|
111
|
-
end
|
|
112
|
-
elsif definition.is_a?(Hash)
|
|
113
|
-
add_param(location_name, name, definition[:type] || definition["type"] || String, required:, **definition.symbolize_keys.except(:type))
|
|
114
|
-
else
|
|
115
|
-
add_param(location_name, name, definition, required:)
|
|
116
|
-
end
|
|
140
|
+
endpoint.request.add_param(
|
|
141
|
+
location_name,
|
|
142
|
+
ActionSpec::Schema.build_field(name, definition, required:, scopes: scopes.dup)
|
|
143
|
+
)
|
|
117
144
|
end
|
|
118
145
|
end
|
|
119
146
|
|
|
120
|
-
def add_body(media_type, definition)
|
|
147
|
+
def add_body(media_type, definition, required:)
|
|
148
|
+
endpoint.request.require_body! if required
|
|
121
149
|
ActionSpec::Schema.build_fields(definition, scopes: scopes.dup).each_value do |field|
|
|
122
150
|
endpoint.request.add_body(media_type, field)
|
|
123
151
|
end
|
|
@@ -23,7 +23,8 @@ module ActionSpec
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def add_response(code, response)
|
|
26
|
-
@responses[code.to_s]
|
|
26
|
+
existing = @responses[code.to_s]
|
|
27
|
+
@responses[code.to_s] = existing ? existing.merge(response) : response
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def copy
|
|
@@ -46,6 +47,7 @@ module ActionSpec
|
|
|
46
47
|
@cookie = Location.new(:cookie)
|
|
47
48
|
@body = Location.new(:body)
|
|
48
49
|
@body_media_types = {}
|
|
50
|
+
@body_required = false
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def location(name)
|
|
@@ -61,6 +63,14 @@ module ActionSpec
|
|
|
61
63
|
(@body_media_types[media_type.to_sym] ||= Location.new(media_type.to_sym)).add(field.copy)
|
|
62
64
|
end
|
|
63
65
|
|
|
66
|
+
def require_body!
|
|
67
|
+
@body_required = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def body_required?
|
|
71
|
+
@body_required
|
|
72
|
+
end
|
|
73
|
+
|
|
64
74
|
def replace_with(other)
|
|
65
75
|
@header = other.header
|
|
66
76
|
@path = other.path
|
|
@@ -68,6 +78,7 @@ module ActionSpec
|
|
|
68
78
|
@cookie = other.cookie
|
|
69
79
|
@body = other.body
|
|
70
80
|
@body_media_types = other.body_media_types
|
|
81
|
+
@body_required = other.body_required?
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
def copy
|
|
@@ -81,6 +92,7 @@ module ActionSpec
|
|
|
81
92
|
:@body_media_types,
|
|
82
93
|
body_media_types.transform_values(&:copy)
|
|
83
94
|
)
|
|
95
|
+
request.instance_variable_set(:@body_required, body_required?)
|
|
84
96
|
end
|
|
85
97
|
end
|
|
86
98
|
end
|
|
@@ -123,18 +135,87 @@ module ActionSpec
|
|
|
123
135
|
end
|
|
124
136
|
|
|
125
137
|
class Response
|
|
126
|
-
attr_reader :code, :description, :
|
|
138
|
+
attr_reader :code, :description, :media_types, :options
|
|
127
139
|
|
|
128
|
-
def initialize(code:, description:, media_type:, options:)
|
|
140
|
+
def initialize(code:, description:, media_type:, schema: nil, example: nil, examples: nil, options:)
|
|
129
141
|
@code = code.to_s
|
|
130
142
|
@description = description
|
|
131
|
-
@
|
|
132
|
-
@options = options
|
|
143
|
+
@media_types = ActiveSupport::OrderedHash.new
|
|
144
|
+
@options = options.deep_dup
|
|
145
|
+
merge_media_type!(media_type || :json, schema:, example:, examples:, initialize: true)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def merge(other)
|
|
149
|
+
copy.tap do |merged|
|
|
150
|
+
merged.instance_variable_set(:@description, other.description.presence || description)
|
|
151
|
+
merged.instance_variable_set(:@options, options.deep_dup.merge(other.options.deep_dup))
|
|
152
|
+
other.media_types.each do |media_type, content|
|
|
153
|
+
merged.send(:merge_media_type!, media_type, **merged.send(:copy_content, content))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
133
156
|
end
|
|
134
157
|
|
|
135
158
|
def copy
|
|
136
|
-
self.class.new(
|
|
159
|
+
self.class.new(
|
|
160
|
+
code:,
|
|
161
|
+
description:,
|
|
162
|
+
media_type: nil,
|
|
163
|
+
schema: nil,
|
|
164
|
+
example: nil,
|
|
165
|
+
examples: nil,
|
|
166
|
+
options: options.deep_dup
|
|
167
|
+
).tap do |response|
|
|
168
|
+
response.instance_variable_set(
|
|
169
|
+
:@media_types,
|
|
170
|
+
media_types.each_with_object(ActiveSupport::OrderedHash.new) do |(media_type, content), hash|
|
|
171
|
+
hash[media_type] = copy_content(content)
|
|
172
|
+
end
|
|
173
|
+
)
|
|
174
|
+
end
|
|
137
175
|
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def merge_media_type!(media_type, schema:, example:, examples:, initialize: false)
|
|
180
|
+
content = (@media_types[media_type.to_sym] ||= {
|
|
181
|
+
schema: nil,
|
|
182
|
+
example: nil,
|
|
183
|
+
examples: ActiveSupport::OrderedHash.new
|
|
184
|
+
})
|
|
185
|
+
content[:schema] = schema || content[:schema]
|
|
186
|
+
|
|
187
|
+
if examples.present?
|
|
188
|
+
convert_example_into_default_example!(content)
|
|
189
|
+
content[:examples].merge!(normalize_examples(examples))
|
|
190
|
+
elsif !example.nil?
|
|
191
|
+
if content[:examples].present? && !initialize
|
|
192
|
+
content[:examples]["default"] ||= example
|
|
193
|
+
else
|
|
194
|
+
content[:example] = example
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def convert_example_into_default_example!(content)
|
|
200
|
+
return if content[:example].nil?
|
|
201
|
+
|
|
202
|
+
content[:examples]["default"] ||= content[:example]
|
|
203
|
+
content[:example] = nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def normalize_examples(examples)
|
|
207
|
+
examples.each_with_object(ActiveSupport::OrderedHash.new) do |(name, value), hash|
|
|
208
|
+
hash[name.to_s] = value
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def copy_content(content)
|
|
213
|
+
{
|
|
214
|
+
schema: content[:schema]&.copy,
|
|
215
|
+
example: content[:example].deep_dup,
|
|
216
|
+
examples: content[:examples].deep_dup
|
|
217
|
+
}
|
|
218
|
+
end
|
|
138
219
|
end
|
|
139
220
|
end
|
|
140
221
|
end
|
|
@@ -52,7 +52,7 @@ module ActionSpec
|
|
|
52
52
|
return { "200" => { "description" => "OK" } } if endpoint.responses.empty?
|
|
53
53
|
|
|
54
54
|
endpoint.responses.each_with_object(ActiveSupport::OrderedHash.new) do |(code, response), hash|
|
|
55
|
-
hash[code] =
|
|
55
|
+
hash[code] = schema.response(response)
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
end
|
|
@@ -36,7 +36,16 @@ module ActionSpec
|
|
|
36
36
|
end
|
|
37
37
|
return if content.empty?
|
|
38
38
|
|
|
39
|
-
{ "content" => content }
|
|
39
|
+
{ "content" => content }.tap do |body|
|
|
40
|
+
body["required"] = true if request.body_required?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def response(response)
|
|
45
|
+
{
|
|
46
|
+
"description" => response.description.presence || "OK",
|
|
47
|
+
"content" => response_content(response).presence
|
|
48
|
+
}.compact
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
def schema_for(schema)
|
|
@@ -50,6 +59,29 @@ module ActionSpec
|
|
|
50
59
|
|
|
51
60
|
private
|
|
52
61
|
|
|
62
|
+
def response_content(response)
|
|
63
|
+
response.media_types.each_with_object(ActiveSupport::OrderedHash.new) do |(media_type, content), hash|
|
|
64
|
+
normalized = response_media_type_content(content)
|
|
65
|
+
next if normalized.blank?
|
|
66
|
+
|
|
67
|
+
hash[MEDIA_TYPE_MAP.fetch(media_type, media_type.to_s)] = normalized
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def response_media_type_content(content)
|
|
72
|
+
{}.tap do |definition|
|
|
73
|
+
if (schema = content[:schema] || infer_schema_from_examples(content)).present?
|
|
74
|
+
definition["schema"] = schema_for(schema)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if (examples = normalize_response_examples(content[:examples])).present?
|
|
78
|
+
definition["examples"] = examples
|
|
79
|
+
elsif (example = normalize_response_example(content[:example])).present?
|
|
80
|
+
definition["example"] = example
|
|
81
|
+
end
|
|
82
|
+
end.presence
|
|
83
|
+
end
|
|
84
|
+
|
|
53
85
|
def parameter_name(field, location)
|
|
54
86
|
return field.name.to_s if location != :header
|
|
55
87
|
|
|
@@ -198,6 +230,79 @@ module ActionSpec
|
|
|
198
230
|
def invalid_openapi_literal
|
|
199
231
|
@invalid_openapi_literal ||= Object.new.freeze
|
|
200
232
|
end
|
|
233
|
+
|
|
234
|
+
def normalize_response_example(example)
|
|
235
|
+
normalized = openapi_literal(example)
|
|
236
|
+
return if normalized.nil? || normalized.equal?(invalid_openapi_literal)
|
|
237
|
+
|
|
238
|
+
normalized
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def normalize_response_examples(examples)
|
|
242
|
+
return if examples.blank?
|
|
243
|
+
|
|
244
|
+
examples.each_with_object(ActiveSupport::OrderedHash.new) do |(name, value), hash|
|
|
245
|
+
normalized = openapi_literal(value)
|
|
246
|
+
next if normalized.nil? || normalized.equal?(invalid_openapi_literal)
|
|
247
|
+
|
|
248
|
+
hash[name.to_s] = { "value" => normalized }
|
|
249
|
+
end.presence
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def infer_schema_from_examples(content)
|
|
253
|
+
values =
|
|
254
|
+
if content[:examples].present?
|
|
255
|
+
content[:examples].values
|
|
256
|
+
elsif !content[:example].nil?
|
|
257
|
+
[content[:example]]
|
|
258
|
+
else
|
|
259
|
+
[]
|
|
260
|
+
end
|
|
261
|
+
return if values.blank?
|
|
262
|
+
|
|
263
|
+
definition = infer_definition(values)
|
|
264
|
+
return if definition.blank?
|
|
265
|
+
|
|
266
|
+
ActionSpec::Schema.from_definition(definition)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def infer_definition(values)
|
|
270
|
+
values = Array(values)
|
|
271
|
+
present_values = values.compact
|
|
272
|
+
return if present_values.empty?
|
|
273
|
+
|
|
274
|
+
return infer_object_definition(present_values) if present_values.all? { |value| value.is_a?(Hash) }
|
|
275
|
+
return infer_array_definition(present_values) if present_values.all? { |value| value.is_a?(Array) }
|
|
276
|
+
|
|
277
|
+
infer_scalar_definition(present_values)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def infer_object_definition(values)
|
|
281
|
+
keys = values.flat_map(&:keys).map(&:to_s).uniq
|
|
282
|
+
keys.each_with_object(ActiveSupport::OrderedHash.new) do |key, definition|
|
|
283
|
+
child_values = values.select { |value| value.key?(key) || value.key?(key.to_sym) }.map { |value| value[key] || value[key.to_sym] }
|
|
284
|
+
child_definition = infer_definition(child_values)
|
|
285
|
+
next if child_definition.blank?
|
|
286
|
+
|
|
287
|
+
name = values.all? { |value| value.key?(key) || value.key?(key.to_sym) } ? :"#{key}!" : key.to_sym
|
|
288
|
+
definition[name] = child_definition
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def infer_array_definition(values)
|
|
293
|
+
flattened = values.flatten(1)
|
|
294
|
+
item_definition = infer_definition(flattened)
|
|
295
|
+
item_definition ? [item_definition] : []
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def infer_scalar_definition(values)
|
|
299
|
+
return Integer if values.all? { |value| value.is_a?(Integer) }
|
|
300
|
+
return Float if values.all? { |value| value.is_a?(Numeric) }
|
|
301
|
+
return :boolean if values.all? { |value| value == true || value == false }
|
|
302
|
+
return String if values.all? { |value| value.is_a?(String) }
|
|
303
|
+
|
|
304
|
+
String
|
|
305
|
+
end
|
|
201
306
|
end
|
|
202
307
|
end
|
|
203
308
|
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:, length:,
|
|
27
|
+
self.class.new(item.copy, default:, enum:, range:, pattern:, length:, 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, :length, :
|
|
6
|
+
attr_reader :default, :enum, :range, :pattern, :length, :blank, :description, :example, :examples
|
|
7
7
|
|
|
8
8
|
def initialize(options = {})
|
|
9
9
|
options = options.symbolize_keys
|
|
@@ -12,17 +12,22 @@ module ActionSpec
|
|
|
12
12
|
@range = options[:range]
|
|
13
13
|
@pattern = options[:pattern]
|
|
14
14
|
@length = options[:length]
|
|
15
|
-
@
|
|
16
|
-
@allow_blank = options[:allow_blank]
|
|
15
|
+
@blank = options.key?(:blank) ? options[:blank] : options.fetch(:allow_blank, true)
|
|
17
16
|
@description = options[:desc] || options[:description]
|
|
18
17
|
@example = options[:example]
|
|
19
18
|
@examples = options[:examples]
|
|
20
19
|
end
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
alias allow_blank blank
|
|
22
|
+
|
|
23
|
+
def materialize_missing(context:, coerce:, result:, path:)
|
|
23
24
|
Schema::Missing
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
def blank_allowed?
|
|
28
|
+
blank != false
|
|
29
|
+
end
|
|
30
|
+
|
|
26
31
|
def validate_constraints(value, result:, path:)
|
|
27
32
|
return if value.nil?
|
|
28
33
|
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
module Schema
|
|
5
5
|
class Field
|
|
6
|
-
attr_reader :name, :schema, :scopes
|
|
6
|
+
attr_reader :name, :schema, :transform, :px_key, :scopes
|
|
7
7
|
|
|
8
|
-
def initialize(name:, required:, schema:, scopes: [])
|
|
8
|
+
def initialize(name:, required:, schema:, transform: nil, px_key: nil, scopes: [])
|
|
9
9
|
@name = name.to_sym
|
|
10
10
|
@required = required
|
|
11
11
|
@schema = schema
|
|
12
|
+
@transform = transform
|
|
13
|
+
@px_key = px_key&.to_sym
|
|
12
14
|
@scopes = Array(scopes).map(&:to_sym).freeze
|
|
13
15
|
end
|
|
14
16
|
|
|
@@ -20,9 +22,48 @@ module ActionSpec
|
|
|
20
22
|
schema.default
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
def output_name
|
|
26
|
+
px_key || name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def transform_value(value, context: nil)
|
|
30
|
+
return value if transform.nil? || value.equal?(ActionSpec::Schema::Missing)
|
|
31
|
+
|
|
32
|
+
case transform
|
|
33
|
+
when Symbol, String then apply_symbol_transform(value, context:)
|
|
34
|
+
when Proc then apply_proc_transform(value, context:)
|
|
35
|
+
else value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
23
39
|
def copy
|
|
24
|
-
self.class.new(name:, required: required?, schema: schema.copy, scopes:)
|
|
40
|
+
self.class.new(name:, required: required?, schema: schema.copy, transform:, px_key:, scopes:)
|
|
25
41
|
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def apply_symbol_transform(value, context:)
|
|
46
|
+
symbol = transform.to_sym
|
|
47
|
+
return value.public_send(symbol) if value.respond_to?(symbol)
|
|
48
|
+
return invoke_context_transform(context, symbol, value) if context&.respond_to?(symbol, true)
|
|
49
|
+
|
|
50
|
+
value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_proc_transform(value, context:)
|
|
54
|
+
return context.instance_exec(&transform) if context && transform.arity.zero?
|
|
55
|
+
return context.instance_exec(value, &transform) if context && (transform.arity == 1 || transform.arity.negative?)
|
|
56
|
+
return transform.call if transform.arity.zero?
|
|
57
|
+
|
|
58
|
+
transform.call(value)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def invoke_context_transform(context, symbol, value)
|
|
62
|
+
method = context.method(symbol)
|
|
63
|
+
return context.public_send(symbol) if method.arity.zero?
|
|
64
|
+
|
|
65
|
+
context.public_send(symbol, value)
|
|
66
|
+
end
|
|
26
67
|
end
|
|
27
68
|
end
|
|
28
69
|
end
|
|
@@ -24,7 +24,7 @@ module ActionSpec
|
|
|
24
24
|
result:,
|
|
25
25
|
path:
|
|
26
26
|
).resolve
|
|
27
|
-
output[field.
|
|
27
|
+
output[field.output_name] = resolved unless resolved.equal?(Schema::Missing)
|
|
28
28
|
end
|
|
29
29
|
output.presence || (source.present? ? output : Schema::Missing)
|
|
30
30
|
end
|
|
@@ -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:, length:,
|
|
37
|
+
self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, length:, blank:, desc: description, example:, examples:)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
private
|
|
@@ -15,7 +15,10 @@ module ActionSpec
|
|
|
15
15
|
def resolve
|
|
16
16
|
return resolve_missing unless present?
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
return resolve_nil if value.nil?
|
|
19
|
+
return resolve_blank if blank_disallowed?
|
|
20
|
+
|
|
21
|
+
finalize(schema.cast(value, context:, coerce:, result:, path:))
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
private
|
|
@@ -36,15 +39,35 @@ module ActionSpec
|
|
|
36
39
|
|
|
37
40
|
def resolve_missing
|
|
38
41
|
if schema.default.respond_to?(:call)
|
|
39
|
-
return schema.cast(evaluate_default(schema.default), context:, coerce:, result:, path:)
|
|
42
|
+
return finalize(schema.cast(evaluate_default(schema.default), context:, coerce:, result:, path:))
|
|
40
43
|
end
|
|
41
|
-
return schema.cast(schema.default, context:, coerce:, result:, path:) unless schema.default.nil?
|
|
42
|
-
return schema.materialize_missing(context:, coerce:, result:, path:) unless field.required?
|
|
44
|
+
return finalize(schema.cast(schema.default, context:, coerce:, result:, path:)) unless schema.default.nil?
|
|
45
|
+
return finalize(schema.materialize_missing(context:, coerce:, result:, path:)) unless field.required?
|
|
43
46
|
|
|
44
47
|
result.add_error(path.join("."), :required)
|
|
45
48
|
Schema::Missing
|
|
46
49
|
end
|
|
47
50
|
|
|
51
|
+
def resolve_nil
|
|
52
|
+
result.add_error(path.join("."), field.required? ? :required : :blank)
|
|
53
|
+
Schema::Missing
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve_blank
|
|
57
|
+
result.add_error(path.join("."), :blank)
|
|
58
|
+
Schema::Missing
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def blank_disallowed?
|
|
62
|
+
!schema.blank_allowed? && value.respond_to?(:blank?) && value.blank?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def finalize(resolved)
|
|
66
|
+
return resolved if resolved.equal?(Schema::Missing)
|
|
67
|
+
|
|
68
|
+
field.transform_value(resolved, context:)
|
|
69
|
+
end
|
|
70
|
+
|
|
48
71
|
def evaluate_default(default_proc)
|
|
49
72
|
return context.instance_exec(&default_proc) if context && default_proc.arity.zero?
|
|
50
73
|
return default_proc.call(context) if context && default_proc.arity == 1
|
|
@@ -14,7 +14,7 @@ module ActionSpec
|
|
|
14
14
|
candidate = TypeCaster.cast(type, value)
|
|
15
15
|
rescue TypeCaster::CastError => error
|
|
16
16
|
result.add_error(path.join("."), :invalid_type, expected: error.expected)
|
|
17
|
-
|
|
17
|
+
Schema::Missing
|
|
18
18
|
else
|
|
19
19
|
return candidate if candidate.nil?
|
|
20
20
|
|
|
@@ -23,7 +23,7 @@ module ActionSpec
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def copy
|
|
26
|
-
self.class.new(type, default:, enum:, range:, pattern:, length:,
|
|
26
|
+
self.class.new(type, default:, enum:, range:, pattern:, length:, blank:, desc: description, example:, examples:)
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
|
@@ -25,7 +25,7 @@ module ActionSpec
|
|
|
25
25
|
return cast_decimal(value) if normalized == :decimal
|
|
26
26
|
|
|
27
27
|
active_model_type_for(normalized).cast(value).tap do |casted|
|
|
28
|
-
raise CastError, normalized if casted.nil? && value.
|
|
28
|
+
raise CastError, normalized if casted.nil? && !value.nil?
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
data/lib/action_spec/schema.rb
CHANGED
|
@@ -12,7 +12,8 @@ require "action_spec/schema/type_caster"
|
|
|
12
12
|
module ActionSpec
|
|
13
13
|
module Schema
|
|
14
14
|
Missing = Object.new.freeze
|
|
15
|
-
OPTION_KEYS = %i[default desc enum range pattern length
|
|
15
|
+
OPTION_KEYS = %i[default desc enum range pattern length blank allow_blank example examples].freeze
|
|
16
|
+
FIELD_OPTION_KEYS = (OPTION_KEYS + %i[required transform px px_key]).freeze
|
|
16
17
|
|
|
17
18
|
class << self
|
|
18
19
|
def build(type = nil, **options)
|
|
@@ -21,6 +22,17 @@ module ActionSpec
|
|
|
21
22
|
from_definition(definition)
|
|
22
23
|
end
|
|
23
24
|
|
|
25
|
+
def build_field(name, definition = nil, required: false, scopes: [])
|
|
26
|
+
Field.new(
|
|
27
|
+
name: field_name(name),
|
|
28
|
+
required: required_key?(name) || required || explicit_required?(definition),
|
|
29
|
+
schema: build_field_schema(strip_field_options(definition)),
|
|
30
|
+
transform: explicit_transform(definition),
|
|
31
|
+
px_key: explicit_px_key(definition),
|
|
32
|
+
scopes:
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
24
36
|
def from_definition(definition)
|
|
25
37
|
return Scalar.new(String) if definition.blank?
|
|
26
38
|
return ArrayOf.new(from_definition(type: definition.first)) if definition.is_a?(Array) && definition.one?
|
|
@@ -42,13 +54,8 @@ module ActionSpec
|
|
|
42
54
|
|
|
43
55
|
def build_fields(definition_hash, scopes: [])
|
|
44
56
|
definition_hash.each_with_object(ActiveSupport::OrderedHash.new) do |(name, definition), fields|
|
|
45
|
-
|
|
46
|
-
fields[
|
|
47
|
-
name: field_name(name),
|
|
48
|
-
required: required_key?(name),
|
|
49
|
-
schema:,
|
|
50
|
-
scopes:
|
|
51
|
-
)
|
|
57
|
+
field = build_field(name, definition, scopes:)
|
|
58
|
+
fields[field.name] = field
|
|
52
59
|
end
|
|
53
60
|
end
|
|
54
61
|
|
|
@@ -64,11 +71,61 @@ module ActionSpec
|
|
|
64
71
|
return from_definition(type: definition) unless definition.is_a?(Hash)
|
|
65
72
|
|
|
66
73
|
definition = definition.symbolize_keys
|
|
67
|
-
return from_definition(definition) if definition.key?(:type)
|
|
68
|
-
return from_definition(definition) if (definition.keys -
|
|
74
|
+
return from_definition(definition.except(:required)) if definition.key?(:type)
|
|
75
|
+
return from_definition(definition.except(:required)) if (definition.keys - FIELD_OPTION_KEYS).present?
|
|
76
|
+
|
|
77
|
+
from_definition(definition.except(:required).merge(type: String))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def schema_definition?(definition)
|
|
81
|
+
case definition
|
|
82
|
+
when Array
|
|
83
|
+
definition.one? && schema_definition?(definition.first)
|
|
84
|
+
when Hash
|
|
85
|
+
definition = definition.with_indifferent_access
|
|
86
|
+
return true if definition.key?(:type)
|
|
87
|
+
return true if definition.keys.all? { |key| FIELD_OPTION_KEYS.include?(key.to_sym) }
|
|
69
88
|
|
|
70
|
-
|
|
89
|
+
definition.any? do |name, value|
|
|
90
|
+
required_key?(name) || schema_definition?(value)
|
|
91
|
+
end
|
|
92
|
+
when Class
|
|
93
|
+
true
|
|
94
|
+
when Symbol
|
|
95
|
+
definition == :boolean || definition == :file || definition == :object
|
|
96
|
+
else
|
|
97
|
+
false
|
|
98
|
+
end
|
|
71
99
|
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def explicit_required?(definition)
|
|
104
|
+
definition.is_a?(Hash) && definition.symbolize_keys[:required] == true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def explicit_transform(definition)
|
|
108
|
+
definition.is_a?(Hash) ? definition.symbolize_keys[:transform] : nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def explicit_px_key(definition)
|
|
112
|
+
return unless definition.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
options = definition.symbolize_keys
|
|
115
|
+
normalize_px_key(options[:px_key] || options[:px])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_px_key(value)
|
|
119
|
+
return if value.nil? || value == true || value == false
|
|
120
|
+
|
|
121
|
+
value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : value
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def strip_field_options(definition)
|
|
125
|
+
return definition unless definition.is_a?(Hash)
|
|
126
|
+
|
|
127
|
+
definition.symbolize_keys.except(:required, :transform, :px, :px_key)
|
|
128
|
+
end
|
|
72
129
|
end
|
|
73
130
|
end
|
|
74
131
|
end
|
|
@@ -13,7 +13,7 @@ module ActionSpec
|
|
|
13
13
|
result = ValidationResult.new
|
|
14
14
|
merge_group!(result, endpoint.request.path, source: path_source, location: :path)
|
|
15
15
|
merge_group!(result, endpoint.request.query, source: params_source, location: :query)
|
|
16
|
-
|
|
16
|
+
merge_body!(result)
|
|
17
17
|
merge_group!(result, endpoint.request.header, source: header_source, location: :headers)
|
|
18
18
|
merge_group!(result, endpoint.request.cookie, source: cookie_source, location: :cookies)
|
|
19
19
|
result
|
|
@@ -23,6 +23,15 @@ module ActionSpec
|
|
|
23
23
|
|
|
24
24
|
attr_reader :endpoint, :controller, :coerce
|
|
25
25
|
|
|
26
|
+
def merge_body!(result)
|
|
27
|
+
if endpoint.request.body_required? && body_source.blank?
|
|
28
|
+
result.add_error("body", :required)
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
merge_group!(result, endpoint.request.body, source: body_source, location: :body)
|
|
33
|
+
end
|
|
34
|
+
|
|
26
35
|
def merge_group!(result, group, source:, location:)
|
|
27
36
|
group.fields.each do |field|
|
|
28
37
|
value = resolve_field(field, result:, source:, location:)
|
|
@@ -49,6 +58,12 @@ module ActionSpec
|
|
|
49
58
|
controller.params.to_unsafe_h
|
|
50
59
|
end
|
|
51
60
|
|
|
61
|
+
def body_source
|
|
62
|
+
return params_source unless controller.request.respond_to?(:request_parameters)
|
|
63
|
+
|
|
64
|
+
controller.request.request_parameters || {}
|
|
65
|
+
end
|
|
66
|
+
|
|
52
67
|
def path_source
|
|
53
68
|
controller.request.path_parameters.except(:controller, :action)
|
|
54
69
|
end
|
|
@@ -70,6 +85,7 @@ module ActionSpec
|
|
|
70
85
|
end
|
|
71
86
|
|
|
72
87
|
def storage_key(field, location)
|
|
88
|
+
return field.output_name if field.px_key.present?
|
|
73
89
|
return field.name.to_s.tr("_", "-").downcase if location == :headers
|
|
74
90
|
|
|
75
91
|
field.name
|
data/lib/action_spec/version.rb
CHANGED