action_spec 1.2.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: a01ddc76d37b180c1564c96963ccce0bbe32cc9efd4c29deede6e1c3e7a8f7c7
4
- data.tar.gz: a97df2927f2f5df0fce593b2ff55fa123d983ab6023690326bce9634ead51b31
3
+ metadata.gz: 432c84ac7303d7194f5617f2fe724bdb2d332662eb543a0545712731cac767dc
4
+ data.tar.gz: 90343cbda95d9a89452aabf86da6731606d2404a4c2bcd0a9358b48f705bd6cb
5
5
  SHA512:
6
- metadata.gz: e5ba8be1ae92c054686603867e11cda5b1af8482113ae2fcd4b36303e9631f1bdfd3c915b5e724a84dcd3c20c29f2a3f46bb92f74654716c3037625125d00820
7
- data.tar.gz: 0d8327b6f04162da2ceedf697ac0a0e3dde789584870ebde8c2769ac43e6bb00836a81386b61d519b2472cd4acd8ffaf19472bbd0490c7911f9295c5216d4ed4
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,24 +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
- - [DSL Reference](#dsl-reference)
18
- - [Schemas](#schemas)
19
- - [Declare A Required Field](#declare-a-required-field)
20
- - [Field Types](#field-types)
21
- - [Field Options](#field-options)
22
- - [Schemas From ActiveRecord](#schemas-from-activerecord)
23
- - [Type And Boundary Matrix](#type-and-boundary-matrix)
24
- - [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
25
- - [Validation Flow](#validation-flow)
26
- - [Reading Processed Values With `px`](#reading-processed-values-with-px)
27
- - [Errors](#errors)
28
- - [Configuration And I18n](#configuration-and-i18n)
29
- - [Configuration](#configuration)
30
- - [I18n](#i18n)
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)
31
37
 
32
38
  ## Example
33
39
 
@@ -56,10 +62,8 @@ class UsersController < ApplicationController
56
62
  }
57
63
  def create
58
64
  User.create!(
59
- account_id: px[:account_id],
60
- name: px[:name],
61
- birthday: px[:birthday],
62
- admin: px[:admin]
65
+ account_id: px[:account_id], name: px[:name],
66
+ **px.slice(:birthday, :admin, :profile)
63
67
  )
64
68
  end
65
69
  end
@@ -92,7 +96,7 @@ By default, this writes to:
92
96
  docs/openapi.yml
93
97
  ```
94
98
 
95
- 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:
96
100
 
97
101
  ```bash
98
102
  bin/rails action_spec:gen \
@@ -146,6 +150,18 @@ def create
146
150
  end
147
151
  ```
148
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
+
149
165
  ### `doc_dry`
150
166
 
151
167
  ```ruby
@@ -163,15 +179,7 @@ end
163
179
 
164
180
  All matching dry blocks are applied before the action-specific `doc`.
165
181
 
166
- You can also opt an action out of OpenAPI generation from either `doc` or `doc_dry`:
167
-
168
- ```ruby
169
- doc {
170
- openapi false
171
- }
172
- ```
173
-
174
- ### DSL Reference
182
+ ### DSL Inside `doc`
175
183
 
176
184
  #### Parameter
177
185
 
@@ -189,7 +197,16 @@ cookie :remember_token, String
189
197
  cookie! :remember_token, String
190
198
  ```
191
199
 
192
- 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
+ ```
193
210
 
194
211
  Batch declaration forms:
195
212
 
@@ -230,11 +247,9 @@ Convenience helpers:
230
247
 
231
248
  ```ruby
232
249
  json data: { name!: String }
233
-
234
250
  json! data: { name!: String }
235
251
 
236
252
  form data: { file!: File, position: String }
237
-
238
253
  form! data: { file!: File }
239
254
  ```
240
255
 
@@ -244,16 +259,23 @@ Single multipart field helper:
244
259
  data :file, File
245
260
  ```
246
261
 
247
- 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`
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.
248
270
 
249
- #### OpenAPI
271
+ #### `openapi false`
272
+
273
+ You can also opt an action out of OpenAPI generation from either `doc` or `doc_dry`:
250
274
 
251
275
  ```ruby
252
276
  openapi false
253
277
  ```
254
278
 
255
- Use this when an action should stay out of the generated OpenAPI document. It also works inside `doc_dry`.
256
-
257
279
  #### Scope
258
280
 
259
281
  Use `scope` when you want a grouped view that spans multiple request locations:
@@ -264,6 +286,7 @@ doc {
264
286
  query :user_id, Integer
265
287
  form data: { name: String }
266
288
  }
289
+ form data: { not_in_scope: String }
267
290
  }
268
291
  ```
269
292
 
@@ -278,11 +301,27 @@ px.scope[:user] # => { user_id: 1, name: "Tom" }
278
301
  ```ruby
279
302
  response 200, desc: "success"
280
303
  response 422, "validation failed"
281
- resp 400, "bad request"
304
+ response 200, :json, data: { code!: Integer, result: Object }
305
+
282
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
+
283
317
  ```
284
318
 
285
- 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.
286
325
 
287
326
  ## Schemas
288
327
 
@@ -305,7 +344,11 @@ Meaning of `!`:
305
344
 
306
345
  - `query!`, `path!`, `header!`, `cookie!` mark the parameter itself as required
307
346
  - keys such as `name!:` or `nickname!:` mark nested object fields as required
308
- - `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`.
309
352
 
310
353
  #### Field Types
311
354
 
@@ -337,26 +380,31 @@ json data: {
337
380
 
338
381
  #### Field Options
339
382
 
340
- These options are currently used by the validator:
341
-
342
383
  ```ruby
343
384
  query :page, Integer, default: 1
344
385
  query :today, Date, default: -> { Time.current.to_date }
345
386
  query :status, String, enum: %w[draft published]
346
387
  query :score, Integer, range: { ge: 1, le: 5 }
347
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
348
394
  ```
349
395
 
350
- These options are currently used by OpenAPI generation, but are not yet used by the runtime validator:
396
+ Notes:
351
397
 
352
- - `desc`
353
- - `example`
354
- - `examples`
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`
355
400
 
356
- These options are not yet used by either the runtime validator or OpenAPI generation:
401
+ These options are used by OpenAPI generation:
357
402
 
358
- - `allow_nil`
359
- - `allow_blank`
403
+ ```ruby
404
+ query :page, Integer, desc: "page number", example: 1, examples: [1, 2, 3]
405
+ ```
406
+
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.
360
408
 
361
409
  #### Schemas From ActiveRecord
362
410
 
@@ -466,6 +514,8 @@ This hook also skips actions without a matching `doc`, so it is safe to declare
466
514
 
467
515
  `px` stores the processed values produced by ActionSpec. With `validate_params!` they stay raw; with `validate_and_coerce_params!` they are coerced values.
468
516
 
517
+ Because `px` is still a hash, you can also use helpers such as `px.slice(...)` to simplify parameter access code.
518
+
469
519
  ```ruby
470
520
  px[:id]
471
521
  px[:page]
@@ -535,6 +585,7 @@ ActionSpec.configure { |config|
535
585
  config.open_api_title = "My API"
536
586
  config.open_api_version = "2026.03"
537
587
  config.open_api_server_url = "https://api.example.com"
588
+ config.default_response_media_type = :json
538
589
 
539
590
  config.error_messages[:invalid_type] = ->(_attribute, options) {
540
591
  "should be coercible to #{options.fetch(:expected)}"
@@ -544,29 +595,13 @@ ActionSpec.configure { |config|
544
595
 
545
596
  Available config keys:
546
597
 
547
- - `invalid_parameters_exception_class`
548
- Default: `ActionSpec::InvalidParameters`.
549
- Controls which exception class is raised when validation fails.
550
-
551
- - `error_messages`
552
- Default: `{}`.
553
- Lets you override error messages by error type, or by attribute plus error type.
554
-
555
- - `open_api_output`
556
- Default: `"docs/openapi.yml"`.
557
- Controls where `bin/rails action_spec:gen` writes the generated OpenAPI document.
558
-
559
- - `open_api_title`
560
- Default: `nil`.
561
- Sets the default OpenAPI `info.title` used by `bin/rails action_spec:gen`.
562
-
563
- - `open_api_version`
564
- Default: `nil`.
565
- Sets the default OpenAPI `info.version` used by `bin/rails action_spec:gen`.
566
-
567
- - `open_api_server_url`
568
- Default: `nil`.
569
- 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.
570
605
 
571
606
  ### I18n
572
607
 
@@ -596,15 +631,26 @@ ActionSpec.configure { |config|
596
631
  }
597
632
  ```
598
633
 
634
+ ## AI Generation Style Guide
635
+
636
+ When using AI tools to generate Rails controller code, and the change involves parameter validation, type coercion, default values, or similar parameter contracts, these conventions work well with ActionSpec:
637
+
638
+ - use `doc { }` or `doc("Summary") { }`; do not add the action name, and do not leave a blank line between the `doc` block and the action method
639
+ - use `{ }` blocks inside `doc` as well; prefer them over `do ... end`
640
+ - when a batch has 3 fields or fewer and does not contain nested hashes, prefer a single-line style, for example:
641
+ - `json data: { type: String, required: true }`
642
+ - `in_query(name: String, value: String)` (prefer `in_xxx(...)` batch declarations over multiple `xx` DSL lines when possible)
643
+ - use `doc_dry`, `scope`, and `transform`、`px(px_key)`、`px.slice` to keep controller concise
644
+ - when request parameters match model declarations, prefer `.schemas` to keep `doc` concise
645
+
599
646
  ## What Is Not Implemented Yet
600
647
 
601
648
  - reusable `components` generation
602
649
  - `$ref` generation and deduplication
603
- - `description`, `operationId`, `tags`, `externalDocs`, `deprecated`, and `security` on operations
650
+ - `description`, `externalDocs`, `deprecated`, and `security` on operations
604
651
  - parameter-level `style`, `explode`, `allowReserved`, `examples`, and richer header/cookie serialization controls
605
652
  - request body `encoding`
606
653
  - multiple request/response media types beyond the current direct DSL mapping
607
- - response body schema generation; current `response` / `resp` / `error` declarations only generate response descriptions
608
654
  - response headers
609
655
  - response links
610
656
  - callbacks
@@ -615,7 +661,7 @@ ActionSpec.configure { |config|
615
661
  - top-level `tags`
616
662
  - top-level `externalDocs`
617
663
  - `jsonSchemaDialect`
618
- - 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`
619
665
 
620
666
  ## Contributing
621
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