action_spec 1.4.0 → 1.6.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: 432c84ac7303d7194f5617f2fe724bdb2d332662eb543a0545712731cac767dc
4
- data.tar.gz: 90343cbda95d9a89452aabf86da6731606d2404a4c2bcd0a9358b48f705bd6cb
3
+ metadata.gz: d4483d08f3dbf8917159d539ee9b064366551409ae646714def0b89595484428
4
+ data.tar.gz: 065af0d1f835124179afef0a756515b6184598cd35ef217a96615f18b2098cbe
5
5
  SHA512:
6
- metadata.gz: 631a11eea99a092b2c104ff3934fcddd1777f8a2346468ef91170ff1bc5acea9844002b438b4ec668a5a20f7a97d58c69bdf8f99482b1b757e4ccf01fadf119c
7
- data.tar.gz: 886a2975a48967efcf844c4577f05c2ebbb4cd8ed2d6c883e80c6830b1388f57b38b9f6e202376b01a71d1109ec75869d20ab143950d60cb5638e98e4b111c02
6
+ metadata.gz: 71ae3fd218312cb0e788f7a5affad8e31da416546e319852509d260490fe261009be24fc51cedfc5948c8c7e9cee04285b50019e613316f3a96605d7cee98b1f
7
+ data.tar.gz: 7aaee031ed042a3631c952789ccf4ca37db7f7a7f53fcb6a45f1a0e8c4badad005cd4e8774fad58d467c8b2f49d543f4d499407b98cad4dde8fe4e6dbe7cc197
data/README.md CHANGED
@@ -16,7 +16,7 @@ Concise and Powerful API Documentation Solution for Rails. [中文](README_zh.md
16
16
  2. [`doc_dry`](#doc_dry)
17
17
  3. [DSL Inside `doc`](#dsl-inside-doc)
18
18
  1. [Parameter](#parameter)
19
- 2. [request body](#request-body)
19
+ 2. [Request body](#request-body)
20
20
  3. [`openapi false`](#openapi-false)
21
21
  4. [Scope](#scope)
22
22
  5. [Response](#response)
@@ -29,7 +29,8 @@ Concise and Powerful API Documentation Solution for Rails. [中文](README_zh.md
29
29
  4. [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
30
30
  1. [Validation Flow](#validation-flow)
31
31
  2. [Reading Processed Values With `px`](#reading-processed-values-with-px)
32
- 3. [Errors](#errors)
32
+ 3. [`px` is Whitelist Extraction](#px-is-whitelist-extraction)
33
+ 4. [Errors](#errors)
33
34
  5. [Configuration And I18n](#configuration-and-i18n)
34
35
  1. [Configuration](#configuration)
35
36
  2. [I18n](#i18n)
@@ -44,25 +45,26 @@ class UsersController < ApplicationController
44
45
  doc {
45
46
  header :Authorization, String
46
47
  path :account_id, Integer
47
- query :locale, String, default: "zh-CN"
48
- query :page, Integer, default: -> { 1 }
48
+ query :locale, String, default: "zh-CN", transform: -> { it.downcase }
49
+ query :key_number, Integer, default: -> { it + 1 }, px: :key
49
50
 
50
51
  form data: {
51
- name!: String,
52
- age: Integer,
52
+ name!: { type: String, transform: :strip },
53
53
  birthday: Date,
54
54
  admin: { type: :boolean, default: false },
55
- tags: [String],
56
- profile: {
57
- nickname!: String
58
- }
55
+ tags: [{ id: Integer, content!: { type: String, blank: false } }],
56
+ profile: { nickname!: String }
59
57
  }
60
58
 
61
- response 200, desc: "success"
59
+ response 200, "success", :json, data: { code: Integer, result: [Hash] }
60
+ errors 400, "client errors", {
61
+ invalid_params: { code: 1234, message: "parameters are invalid" },
62
+ missing_params: { code: 1235, message: "parameters are missing" }
63
+ }
62
64
  }
63
65
  def create
64
- User.create!(
65
- account_id: px[:account_id], name: px[:name],
66
+ User.find(px[:account_id]).update!(
67
+ key: px[:key], name: px[:name],
66
68
  **px.slice(:birthday, :admin, :profile)
67
69
  )
68
70
  end
@@ -199,6 +201,16 @@ cookie! :remember_token, String
199
201
 
200
202
  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
203
 
204
+ You can also change that default globally:
205
+
206
+ ```ruby
207
+ ActionSpec.configure do |config|
208
+ config.required_allow_blank = false
209
+ end
210
+ ```
211
+
212
+ With `required_allow_blank = false`, required fields reject blank strings unless that field explicitly sets `blank:` or `allow_blank:`.
213
+
202
214
  If you prefer not to use bang methods, you can also write `required: true`:
203
215
 
204
216
  ```ruby
@@ -235,7 +247,7 @@ in_query!(
235
247
  )
236
248
  ```
237
249
 
238
- #### request body
250
+ #### Request body
239
251
 
240
252
  General form:
241
253
 
@@ -296,7 +308,28 @@ Then read it from `px.scope`:
296
308
  px.scope[:user] # => { user_id: 1, name: "Tom" }
297
309
  ```
298
310
 
299
- #### Response
311
+ You can also trim custom scope buckets with `compact:` or `compact_blank:`:
312
+
313
+ ```ruby
314
+ doc {
315
+ scope(:search, compact: true) {
316
+ query :page, Integer, transform: -> { nil }, px: :page_number
317
+ query :keyword, String
318
+ }
319
+
320
+ scope(:filters, compact_blank: true) {
321
+ query :q, String, transform: :strip
322
+ query :nickname, String, transform: -> { "" }
323
+ }
324
+ }
325
+
326
+ px.scope[:search] # => { keyword: "rails" }
327
+ px.scope[:filters] # => { q: "ruby" }
328
+ ```
329
+
330
+ These options only apply to the custom `px.scope[:name]` bucket defined by that `scope`, and use shallow hash compaction.
331
+
332
+ #### Response
300
333
 
301
334
  ```ruby
302
335
  response 200, desc: "success"
@@ -348,7 +381,9 @@ Meaning of `!`:
348
381
 
349
382
  You can also use `required: true` instead of bang syntax for parameters, nested fields, and the root request body.
350
383
 
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`.
384
+ `required` in ActionSpec means "present and not `nil`". By default it does not reject blank strings. You can keep that default, change it globally with `config.required_allow_blank`, or override it per field with `blank:` / `allow_blank:`.
385
+
386
+ When blank values are allowed, type coercion does not fail just because the input is an empty string. If the field can still carry a meaningful blank value, such as `String`, the original blank string stays in `px`. Otherwise, ActionSpec stores `nil` for that field. For example, `""` stays `""` for `String`, but becomes `nil` for `Date`.
352
387
 
353
388
  #### Field Types
354
389
 
@@ -363,21 +398,37 @@ Scalar types currently supported by validation/coercion:
363
398
  - `DateTime`
364
399
  - `Time`
365
400
  - `File`
366
- - `Object`
401
+ - `Object` / `Hash`
367
402
 
368
- Nested forms:
403
+ ```ruby
404
+ query :page, Integer
405
+ form data: { file: File }
406
+ ```
407
+
408
+ Object and Nested Object forms:
369
409
 
370
410
  ```ruby
371
411
  json data: {
372
- tags: [String],
373
- profile: {
374
- nickname!: String
375
- },
376
- settings: { type: Object },
377
- users: [{ id: Integer }]
412
+ settings: Object,
413
+ # == settings: { type: Object }
414
+ # == settings: { type: Hash }
415
+ # == settings: { type: { } }
416
+
417
+ profile: { nickname!: String },
418
+ tags: [],
419
+ # == tags: { type: [] }
420
+ foo: [{ id: Integer }],
421
+
422
+ users: {
423
+ type: { name!: String },
424
+ default: { name: "Tom" },
425
+ transform: -> { { name: it[:name].downcase } }
426
+ }
378
427
  }
379
428
  ```
380
429
 
430
+ When you write `xx: { type: ... }`, the type of `xx` comes from `type`. In other words, `type` is a reserved schema keyword here, not a normal nested field name.
431
+
381
432
  #### Field Options
382
433
 
383
434
  ```ruby
@@ -386,17 +437,77 @@ query :today, Date, default: -> { Time.current.to_date }
386
437
  query :status, String, enum: %w[draft published]
387
438
  query :score, Integer, range: { ge: 1, le: 5 }
388
439
  query :slug, String, pattern: /\A[a-z\-]+\z/
389
- query :title, String, blank: false # or allow_blank: false
440
+ query :title, String, blank: false
390
441
 
391
442
  query :nickname, String, transform: :downcase
392
443
  query :page, Integer, transform: -> { it + 1 }, px: :page_number
393
- query :request_id, String, px_key: :trace_id
444
+ query :end_at, Integer, validate: -> { current_user && it >= px[:start_at] }
445
+ query :birthday, Date, error: "birthday error"
394
446
  ```
395
447
 
396
- Notes:
448
+ - `required`
449
+ - Marks the field as required.
450
+ - Can replace bang syntax when you do not use `name!:`.
451
+ - `default`
452
+ - Default value used when the field is missing.
453
+ - Can be a literal or `-> { }`.
454
+ - `enum`
455
+ - Restricts the field to values from a fixed set.
456
+ - `range`
457
+ - Numeric range constraints.
458
+ - Available: `ge` / `gt` / `le` / `lt`
459
+ - `pattern`
460
+ - Regex constraint.
461
+ - `length`
462
+ - Length constraints.
463
+ - Available: `minimum` / `maximum` / `is`
464
+ - `blank` / `allow_blank`
465
+ - Controls whether blank values are allowed.
466
+ - For fields such as `Date`, if blank is allowed, no type coercion is applied and the value becomes `nil`.
467
+
468
+ - `desc`
469
+ - Used only for OpenAPI description generation.
470
+ - `example`
471
+ - Used only for generating a single OpenAPI example.
472
+ - `examples`
473
+ - Used only for generating multiple OpenAPI examples.
474
+
475
+ - `transform`
476
+ - Applies one more custom transformation to the **already-coerced value**.
477
+ - Accepts a `Symbol` or a `Proc`.
478
+ - `px` / `px_key`
479
+ - Customize the key name used when the parameter is written into `px`.
480
+ - `validate`
481
+ - Accepts a `Proc`.
482
+ - Runs **after all parameters have finished resolving, coercion, transform, and writing into `px`**.
483
+ - Runs in the current controller context, so it can read `px` and directly call controller methods such as `current_user`.
484
+ - When `validate` returns `false` or `nil`, the field adds an `invalid` error.
485
+ - `error` / `error_message`
486
+ - Override the error message used when that field fails validation or coercion.
487
+ - Supported forms:
488
+ - `String`
489
+ - `-> { }`
490
+ - `->(error, value) { }`
491
+ - Field-level `error` / `error_message` have higher priority than global `config.error_messages`.
492
+
493
+ About nested object fields
494
+
495
+ Inner field options run first, and the outer object field runs last.
496
+ In other words, nested `transform` / `px` first participate in building the object, and after the whole object has been built, the outer field receives that final object and continues processing it.
497
+ Only after the whole object tree has finished resolving, coercing, and transforming does ActionSpec enter the post-`validate` phase: inner field `validate` callbacks run first, and outer object field `validate` callbacks run afterwards.
397
498
 
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`
499
+ ```ruby
500
+ json data: {
501
+ user: {
502
+ type: {
503
+ name: { type: String, transform: :strip, px: :nickname }
504
+ },
505
+ transform: -> { { name: it[:nickname].downcase } }
506
+ }
507
+ }
508
+
509
+ px[:user] # => { "name" => "tom" }
510
+ ```
400
511
 
401
512
  These options are used by OpenAPI generation:
402
513
 
@@ -420,7 +531,7 @@ class UsersController < ApplicationController
420
531
  end
421
532
  ```
422
533
 
423
- `User.schemas` returns a hash that can be passed directly into `form data:`, `json data:`, or `body`.
534
+ `User.schemas` returns a symbol-keyed hash that can be passed directly into `form data:`, `json data:`, or `body`.
424
535
 
425
536
  By default, it includes all model fields:
426
537
 
@@ -434,10 +545,46 @@ You can also limit the exported fields:
434
545
  User.schemas(only: %i[name phone role])
435
546
  ```
436
547
 
548
+ Or exclude specific fields:
549
+
550
+ ```ruby
551
+ User.schemas(except: %i[phone role])
552
+ ```
553
+
554
+ When `only:` and `except:` are used together, ActionSpec applies `except:` after `only:`.
555
+
556
+ You can also extract validators for a specific validation context:
557
+
558
+ ```ruby
559
+ User.schemas(on: :create)
560
+ ```
561
+
562
+ You can also override requiredness in the exported schema:
563
+ When `required:` is an array, only the listed fields are treated as required, and every other exported field is treated as non-required.
564
+
565
+ ```ruby
566
+ User.schemas(required: true)
567
+ User.schemas(required: false)
568
+ User.schemas(required: %i[name role])
569
+ ```
570
+
571
+ You can also merge custom schema fragments into the exported fields:
572
+
573
+ ```ruby
574
+ User.schemas(merge: { name: { required: false, desc: "nickname" } })
575
+ ```
576
+
577
+ `bang:` defaults to `true`, so required fields are emitted as bang keys such as `name!:`. `only:` and `except:` both accept plain names or bang-style names such as `phone!`. If you prefer plain keys, you can pass `bang: false`, and required fields will be emitted as `required: true` instead:
578
+
579
+ ```ruby
580
+ User.schemas(bang: false)
581
+ ```
582
+
437
583
  ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel when available, including:
438
584
 
439
585
  - field type
440
- - requiredness, rendered as bang keys such as `"name!"`
586
+ - requiredness, rendered either as bang keys such as `name!:` or as `required: true` when `bang: false`
587
+ - `allow_blank: false` from presence validators unless that validator explicitly allows blank
441
588
  - enum values from `enum`
442
589
  - `default`
443
590
  - `desc` from column comments
@@ -445,15 +592,19 @@ ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel
445
592
  - `range` from numericality validators
446
593
  - `length` from length validators and string column limits
447
594
 
595
+ Conditional validators with `if:` or `unless:` are skipped during schema extraction, because they cannot be represented as unconditional static schema rules. Validators with `on:` / `except_on:` are skipped by default, but can be extracted by passing `schemas(on: ...)`. The `required:` option overrides the requiredness of exported fields only; it does not remove other extracted constraints such as `allow_blank: false`. The `merge:` option deep-merges custom fragments into each extracted field definition.
596
+
448
597
  Example output:
449
598
 
450
599
  ```ruby
451
600
  User.schemas
452
601
  # {
453
- # "name!" => { type: String, desc: "user name", length: { maximum: 20 } },
454
- # "phone!" => { type: String, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
455
- # "role" => { type: String, enum: %w[admin member visitor] }
602
+ # name!: { type: String, desc: "user name", length: { maximum: 20 } },
603
+ # phone!: { type: String, allow_blank: false, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
604
+ # role: { type: String, enum: %w[admin member visitor] }
456
605
  # }
606
+ User.schemas(bang: false)
607
+ # { name: { type: String, required: true, desc: "user name", length: { maximum: 20 } }, ... }
457
608
  ```
458
609
 
459
610
  #### Type And Boundary Matrix
@@ -532,12 +683,13 @@ px.scope[:query]
532
683
  px.scope[:body]
533
684
  px.scope[:headers]
534
685
  px.scope[:cookies]
686
+ px.scope[:the_scope_you_defined]
535
687
  ```
536
688
 
537
689
  Notes:
538
690
 
539
691
  - every declared field from path/query/body is also flattened into the top-level `px[:field]`
540
- - custom `scope(:name)` buckets are also exposed through `px.scope[:name]`
692
+ - `px` itself is an `ActiveSupport::HashWithIndifferentAccess`, and nested object values such as `px[:profile]` are also returned as indifferent hashes
541
693
  - headers and cookies stay inside their own grouped buckets; for example, `px[:Authorization]` is not a top-level shortcut
542
694
  - header keys are stored in lowercase dashed form, but reading remains compatible with original forms such as `Authorization` and `HTTP_AUTHORIZATION`, for example:
543
695
 
@@ -549,6 +701,28 @@ px.scope[:headers]["HTTP_AUTHORIZATION"]
549
701
 
550
702
  - original `params` are not mutated
551
703
 
704
+ ### `px` is Whitelist Extraction
705
+
706
+ ```ruby
707
+ json data: {
708
+ profile: {
709
+ nickname: String
710
+ }
711
+ }
712
+
713
+ # request params
714
+ {
715
+ profile: {
716
+ nickname: "Neo",
717
+ role: "admin"
718
+ }
719
+ }
720
+
721
+ px[:profile] # => { "nickname" => "Neo" }
722
+ ```
723
+
724
+ Undeclared fields are filtered out, including extra keys inside nested objects.
725
+
552
726
  ### Errors
553
727
 
554
728
  Validation errors are stored in `ActiveModel::Errors`.
@@ -631,6 +805,18 @@ ActionSpec.configure { |config|
631
805
  }
632
806
  ```
633
807
 
808
+ If you want to override one specific field directly in the DSL, use `error` or `error_message` on that field:
809
+
810
+ ```ruby
811
+ doc {
812
+ query! :page, Integer, error: "choose a page first"
813
+ query :role, String, validate: -> { false }, error_message: -> { "is not allowed for #{current_user}" }
814
+ json data: {
815
+ birthday!: { type: Date, error_message: ->(error, value) { "#{error}: #{value.inspect}" } }
816
+ }
817
+ }
818
+ ```
819
+
634
820
  ## AI Generation Style Guide
635
821
 
636
822
  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:
@@ -640,6 +826,7 @@ When using AI tools to generate Rails controller code, and the change involves p
640
826
  - when a batch has 3 fields or fewer and does not contain nested hashes, prefer a single-line style, for example:
641
827
  - `json data: { type: String, required: true }`
642
828
  - `in_query(name: String, value: String)` (prefer `in_xxx(...)` batch declarations over multiple `xx` DSL lines when possible)
829
+ - use `!` but not `required: true`
643
830
  - use `doc_dry`, `scope`, and `transform`、`px(px_key)`、`px.slice` to keep controller concise
644
831
  - when request parameters match model declarations, prefer `.schemas` to keep `doc` concise
645
832
 
@@ -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, :default_response_media_type
6
+ :open_api_server_url, :default_response_media_type, :required_allow_blank
7
7
  attr_reader :error_messages
8
8
 
9
9
  def initialize
@@ -13,6 +13,7 @@ module ActionSpec
13
13
  @open_api_version = nil
14
14
  @open_api_server_url = nil
15
15
  @default_response_media_type = :json
16
+ @required_allow_blank = true
16
17
  @error_messages = ActiveSupport::HashWithIndifferentAccess.new
17
18
  end
18
19
 
@@ -35,6 +36,7 @@ module ActionSpec
35
36
  copy.open_api_version = open_api_version
36
37
  copy.open_api_server_url = open_api_server_url
37
38
  copy.default_response_media_type = default_response_media_type
39
+ copy.required_allow_blank = required_allow_blank
38
40
  copy.error_messages = error_messages.deep_dup
39
41
  end
40
42
  end
@@ -56,7 +56,8 @@ module ActionSpec
56
56
  add_body(:form, { name => options.merge(type:) })
57
57
  end
58
58
 
59
- def scope(name, &block)
59
+ def scope(name, compact: nil, compact_blank: nil, &block)
60
+ endpoint.request.register_scope(name, compact:, compact_blank:)
60
61
  scopes.push(name.to_sym)
61
62
  instance_exec(&block)
62
63
  ensure
@@ -38,7 +38,7 @@ module ActionSpec
38
38
  end
39
39
 
40
40
  class Request
41
- attr_reader :header, :path, :query, :cookie, :body, :body_media_types
41
+ attr_reader :header, :path, :query, :cookie, :body, :body_media_types, :scope_options
42
42
 
43
43
  def initialize
44
44
  @header = Location.new(:header)
@@ -47,6 +47,7 @@ module ActionSpec
47
47
  @cookie = Location.new(:cookie)
48
48
  @body = Location.new(:body)
49
49
  @body_media_types = {}
50
+ @scope_options = ActiveSupport::HashWithIndifferentAccess.new
50
51
  @body_required = false
51
52
  end
52
53
 
@@ -63,6 +64,16 @@ module ActionSpec
63
64
  (@body_media_types[media_type.to_sym] ||= Location.new(media_type.to_sym)).add(field.copy)
64
65
  end
65
66
 
67
+ def register_scope(name, compact: nil, compact_blank: nil)
68
+ key = name.to_sym
69
+ @scope_options[key] = scope_options.fetch(key, {}).merge(
70
+ {
71
+ compact:,
72
+ compact_blank:
73
+ }.compact
74
+ )
75
+ end
76
+
66
77
  def require_body!
67
78
  @body_required = true
68
79
  end
@@ -78,6 +89,7 @@ module ActionSpec
78
89
  @cookie = other.cookie
79
90
  @body = other.body
80
91
  @body_media_types = other.body_media_types
92
+ @scope_options = other.scope_options
81
93
  @body_required = other.body_required?
82
94
  end
83
95
 
@@ -92,9 +104,14 @@ module ActionSpec
92
104
  :@body_media_types,
93
105
  body_media_types.transform_values(&:copy)
94
106
  )
107
+ request.instance_variable_set(:@scope_options, scope_options.deep_dup)
95
108
  request.instance_variable_set(:@body_required, body_required?)
96
109
  end
97
110
  end
111
+
112
+ def custom_validation?
113
+ [header, path, query, cookie, body].any?(&:custom_validation?)
114
+ end
98
115
  end
99
116
 
100
117
  class Location
@@ -132,6 +149,10 @@ module ActionSpec
132
149
  fields.each { |field| location.add(field.copy) }
133
150
  end
134
151
  end
152
+
153
+ def custom_validation?
154
+ fields.any?(&:custom_validation?)
155
+ end
135
156
  end
136
157
 
137
158
  class Response