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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02c55ca1816d1b61ad17a4a735c1704bb887cc4f67d842a7ed351641c47e93d8
4
- data.tar.gz: a148b5f034a92b4f6648c3deb5e29aa997588e6162cd761e3586e9840d6b0fa2
3
+ metadata.gz: 432c84ac7303d7194f5617f2fe724bdb2d332662eb543a0545712731cac767dc
4
+ data.tar.gz: 90343cbda95d9a89452aabf86da6731606d2404a4c2bcd0a9358b48f705bd6cb
5
5
  SHA512:
6
- metadata.gz: 1eb2aa363f52d5800497364315168186b3fb04fc0fc1541ebe2075a73284585a5302b68f48183f5163451607f53bddc42474a988a6549dcb4daf7849bd2bf9aa
7
- data.tar.gz: 985e5bd7551b4f6bd9919209137c90cae86a3a2720113dacb01e421849fca4e16a602550fb833a920d703f997c97a2b8b87c49ceda5ce4ecefa9bfea1a641988
6
+ metadata.gz: 631a11eea99a092b2c104ff3934fcddd1777f8a2346468ef91170ff1bc5acea9844002b438b4ec668a5a20f7a97d58c69bdf8f99482b1b757e4ccf01fadf119c
7
+ data.tar.gz: 886a2975a48967efcf844c4577f05c2ebbb4cd8ed2d6c883e80c6830b1388f57b38b9f6e202376b01a71d1109ec75869d20ab143950d60cb5638e98e4b111c02
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # ActionSpec [WIP]
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
- - [OpenAPI Generation](#openapi-generation)
14
- - [Doc DSL](#doc-dsl)
15
- - [`doc`](#doc)
16
- - [`doc_dry`](#doc_dry)
17
- - [`openapi false`](#openapi-false)
18
- - [`tag`](#tag)
19
- - [DSL Reference](#dsl-reference)
20
- - [Schemas](#schemas)
21
- - [Declare A Required Field](#declare-a-required-field)
22
- - [Field Types](#field-types)
23
- - [Field Options](#field-options)
24
- - [Schemas From ActiveRecord](#schemas-from-activerecord)
25
- - [Type And Boundary Matrix](#type-and-boundary-matrix)
26
- - [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
27
- - [Validation Flow](#validation-flow)
28
- - [Reading Processed Values With `px`](#reading-processed-values-with-px)
29
- - [Errors](#errors)
30
- - [Configuration And I18n](#configuration-and-i18n)
31
- - [Configuration](#configuration)
32
- - [I18n](#i18n)
33
- - [AI Generation Style Guide](#ai-generation-style-guide)
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
- name: px[:name],
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
- For one-off runs, environment variables can override the default output path and document metadata:
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
- ### `openapi false`
170
-
171
- You can also opt an action out of OpenAPI generation from either `doc` or `doc_dry`:
172
-
173
- ```ruby
174
- doc {
175
- openapi false
176
- }
177
- ```
178
-
179
- ### `tag`
180
-
181
- OpenAPI tags can also be set at either level:
182
-
183
- ```ruby
184
- doc_dry(:index, tag: "backoffice")
185
-
186
- doc("List users", tag: "members") {
187
- query :status, String
188
- }
189
- ```
190
-
191
- Generated OpenAPI operations also include an `operationId`, built from the final tag plus the action name, for example `members_index` or `users_create`.
192
-
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
- 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.
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
- #### OpenAPI
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
- resp 400, "bad request"
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 now. They are not yet used to render responses automatically.
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!` are currently accepted for DSL consistency, but today they behave the same as the non-bang form at runtime
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
- These options are currently used by OpenAPI generation, but are not yet used by the runtime validator:
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
- - `desc`
372
- - `example`
373
- - `examples`
401
+ These options are used by OpenAPI generation:
374
402
 
375
- These options are not yet used by either the runtime validator or OpenAPI generation:
403
+ ```ruby
404
+ query :page, Integer, desc: "page number", example: 1, examples: [1, 2, 3]
405
+ ```
376
406
 
377
- - `allow_nil`
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
- Default: `ActionSpec::InvalidParameters`.
569
- Controls which exception class is raised when validation fails.
570
-
571
- - `error_messages`
572
- Default: `{}`.
573
- Lets you override error messages by error type, or by attribute plus error type.
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 reduce repetition in controllers
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 nullable/blank semantics, object-level constraints, and composition keywords such as `oneOf`, `anyOf`, `allOf`, and `not`
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
@@ -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
- def response(code, description = nil, media_type = nil, desc: nil, **options)
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 || desc.to_s,
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
- alias resp response
83
- alias error response
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
- schema = ActionSpec::Schema.build(type, **options)
92
- endpoint.request.add_param(location_name, ActionSpec::Schema::Field.new(name:, required:, schema:, scopes: scopes.dup))
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
- if definition.is_a?(Hash) && !definition.key?(:type) && !definition.key?("type")
98
- schema_options = definition.symbolize_keys
99
- if (schema_options.keys - ActionSpec::Schema::OPTION_KEYS).present?
100
- endpoint.request.add_param(
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] = response
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, :media_type, :options
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
- @media_type = media_type
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(code:, description:, media_type:, options: options.deep_dup)
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] = { "description" => response.description.presence || "OK" }
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:, allow_nil:, allow_blank:, desc: description, example:, examples:)
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, :allow_nil, :allow_blank, :description, :example, :examples
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
- @allow_nil = options[:allow_nil]
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
- def materialize_missing(_context:, _coerce:, _result:, _path:)
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.name] = resolved unless resolved.equal?(Schema::Missing)
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:, allow_nil:, allow_blank:, desc: description, example:, examples:)
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
- schema.cast(value, context:, coerce:, result:, path:)
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
- nil
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:, allow_nil:, allow_blank:, desc: description, example:, examples:)
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.present?
28
+ raise CastError, normalized if casted.nil? && !value.nil?
29
29
  end
30
30
  end
31
31
 
@@ -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 allow_nil allow_blank example examples].freeze
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
- schema = build_field_schema(definition)
46
- fields[field_name(name)] = Field.new(
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 - OPTION_KEYS).present?
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
- from_definition(definition.merge(type: String))
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
- merge_group!(result, endpoint.request.body, source: params_source, location: :body)
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
@@ -1,3 +1,3 @@
1
1
  module ActionSpec
2
- VERSION = "1.3.0"
2
+ VERSION = "1.4.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: action_spec
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhandao