action_spec 1.4.0 → 1.5.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: 9218555ca7d33709a7a5b22528f6900358e36247c123e521f2342d7a36ac02b5
4
+ data.tar.gz: e771af58669e707ccbcc922b6d9afa726a2b25817ab4836e01ce1154bd22344b
5
5
  SHA512:
6
- metadata.gz: 631a11eea99a092b2c104ff3934fcddd1777f8a2346468ef91170ff1bc5acea9844002b438b4ec668a5a20f7a97d58c69bdf8f99482b1b757e4ccf01fadf119c
7
- data.tar.gz: 886a2975a48967efcf844c4577f05c2ebbb4cd8ed2d6c883e80c6830b1388f57b38b9f6e202376b01a71d1109ec75869d20ab143950d60cb5638e98e4b111c02
6
+ metadata.gz: 7d03ee8f19de7c1c874302344eadf224f9aa3fba2dab228f2f4b44448a2865ba53dd97385587b6b9691308e4a987048b1eab6e5f55a1b03ef93424a643857a0c
7
+ data.tar.gz: 357fe37dc9a591740079c76dd3e4238a9485cb0d1e57ab020bed09bf737d1937ef22b777f8b062f78473fe497965c2df754ee3858a97f77bdd59674002cdda69
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
@@ -235,7 +237,7 @@ in_query!(
235
237
  )
236
238
  ```
237
239
 
238
- #### request body
240
+ #### Request body
239
241
 
240
242
  General form:
241
243
 
@@ -296,7 +298,28 @@ Then read it from `px.scope`:
296
298
  px.scope[:user] # => { user_id: 1, name: "Tom" }
297
299
  ```
298
300
 
299
- #### Response
301
+ You can also trim custom scope buckets with `compact:` or `compact_blank:`:
302
+
303
+ ```ruby
304
+ doc {
305
+ scope(:search, compact: true) {
306
+ query :page, Integer, transform: -> { nil }, px: :page_number
307
+ query :keyword, String
308
+ }
309
+
310
+ scope(:filters, compact_blank: true) {
311
+ query :q, String, transform: :strip
312
+ query :nickname, String, transform: -> { "" }
313
+ }
314
+ }
315
+
316
+ px.scope[:search] # => { keyword: "rails" }
317
+ px.scope[:filters] # => { q: "ruby" }
318
+ ```
319
+
320
+ These options only apply to the custom `px.scope[:name]` bucket defined by that `scope`, and use shallow hash compaction.
321
+
322
+ #### Response
300
323
 
301
324
  ```ruby
302
325
  response 200, desc: "success"
@@ -363,21 +386,37 @@ Scalar types currently supported by validation/coercion:
363
386
  - `DateTime`
364
387
  - `Time`
365
388
  - `File`
366
- - `Object`
389
+ - `Object` / `Hash`
367
390
 
368
- Nested forms:
391
+ ```ruby
392
+ query :page, Integer
393
+ form data: { file: File }
394
+ ```
395
+
396
+ Object and Nested Object forms:
369
397
 
370
398
  ```ruby
371
399
  json data: {
372
- tags: [String],
373
- profile: {
374
- nickname!: String
375
- },
376
- settings: { type: Object },
377
- users: [{ id: Integer }]
400
+ settings: Object,
401
+ # == settings: { type: Object }
402
+ # == settings: { type: Hash }
403
+ # == settings: { type: { } }
404
+
405
+ profile: { nickname!: String },
406
+ tags: [],
407
+ # == tags: { type: [] }
408
+ foo: [{ id: Integer }],
409
+
410
+ users: {
411
+ type: { name!: String },
412
+ default: { name: "Tom" },
413
+ transform: -> { { name: it[:name].downcase } }
414
+ }
378
415
  }
379
416
  ```
380
417
 
418
+ 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.
419
+
381
420
  #### Field Options
382
421
 
383
422
  ```ruby
@@ -391,12 +430,35 @@ query :title, String, blank: false # or allow_blank: false
391
430
  query :nickname, String, transform: :downcase
392
431
  query :page, Integer, transform: -> { it + 1 }, px: :page_number
393
432
  query :request_id, String, px_key: :trace_id
433
+ query :end_at, Integer, validate: -> { it >= px[:start_at] }
394
434
  ```
395
435
 
396
436
  Notes:
397
437
 
398
438
  - `transform` accepts a `Symbol` or a `Proc` and runs **after coercion**, before the value is written into `px`
399
439
  - `px` and `px_key` customize the key name written into `px`; `px` is the short form of `px_key`
440
+ - `validate` accepts a `Proc` and runs **after all parameters have been resolved, coerced, transformed, and written into `px`**
441
+ - `validate` runs in the current controller context, so it can read `px` and call methods such as `current_user`
442
+ - when `validate` returns `false` or `nil`, the field adds an `invalid` error
443
+
444
+ About nested object fields
445
+
446
+ Inner field options run first, and the outer object field runs last.
447
+ 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.
448
+ 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.
449
+
450
+ ```ruby
451
+ json data: {
452
+ user: {
453
+ type: {
454
+ name: { type: String, transform: :strip, px: :nickname }
455
+ },
456
+ transform: -> { { name: it[:nickname].downcase } }
457
+ }
458
+ }
459
+
460
+ px[:user] # => { "name" => "tom" }
461
+ ```
400
462
 
401
463
  These options are used by OpenAPI generation:
402
464
 
@@ -434,10 +496,16 @@ You can also limit the exported fields:
434
496
  User.schemas(only: %i[name phone role])
435
497
  ```
436
498
 
499
+ `bang:` defaults to `true`, so required fields are emitted as bang keys such as `"name!"`. If you prefer plain keys, you can pass `bang: false`, and required fields will be emitted as `required: true` instead:
500
+
501
+ ```ruby
502
+ User.schemas(bang: false)
503
+ ```
504
+
437
505
  ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel when available, including:
438
506
 
439
507
  - field type
440
- - requiredness, rendered as bang keys such as `"name!"`
508
+ - requiredness, rendered either as bang keys such as `"name!"` or as `required: true` when `bang: false`
441
509
  - enum values from `enum`
442
510
  - `default`
443
511
  - `desc` from column comments
@@ -454,6 +522,13 @@ User.schemas
454
522
  # "phone!" => { type: String, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
455
523
  # "role" => { type: String, enum: %w[admin member visitor] }
456
524
  # }
525
+
526
+ User.schemas(bang: false)
527
+ # {
528
+ # "name" => { type: String, required: true, desc: "user name", length: { maximum: 20 } },
529
+ # "phone" => { type: String, required: true, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
530
+ # "role" => { type: String, enum: %w[admin member visitor] }
531
+ # }
457
532
  ```
458
533
 
459
534
  #### Type And Boundary Matrix
@@ -532,12 +607,13 @@ px.scope[:query]
532
607
  px.scope[:body]
533
608
  px.scope[:headers]
534
609
  px.scope[:cookies]
610
+ px.scope[:the_scope_you_defined]
535
611
  ```
536
612
 
537
613
  Notes:
538
614
 
539
615
  - 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]`
616
+ - `px` itself is an `ActiveSupport::HashWithIndifferentAccess`, and nested object values such as `px[:profile]` are also returned as indifferent hashes
541
617
  - headers and cookies stay inside their own grouped buckets; for example, `px[:Authorization]` is not a top-level shortcut
542
618
  - header keys are stored in lowercase dashed form, but reading remains compatible with original forms such as `Authorization` and `HTTP_AUTHORIZATION`, for example:
543
619
 
@@ -549,6 +625,28 @@ px.scope[:headers]["HTTP_AUTHORIZATION"]
549
625
 
550
626
  - original `params` are not mutated
551
627
 
628
+ ### `px` is Whitelist Extraction
629
+
630
+ ```ruby
631
+ json data: {
632
+ profile: {
633
+ nickname: String
634
+ }
635
+ }
636
+
637
+ # request params
638
+ {
639
+ profile: {
640
+ nickname: "Neo",
641
+ role: "admin"
642
+ }
643
+ }
644
+
645
+ px[:profile] # => { "nickname" => "Neo" }
646
+ ```
647
+
648
+ Undeclared fields are filtered out, including extra keys inside nested objects.
649
+
552
650
  ### Errors
553
651
 
554
652
  Validation errors are stored in `ActiveModel::Errors`.
@@ -640,6 +738,7 @@ When using AI tools to generate Rails controller code, and the change involves p
640
738
  - when a batch has 3 fields or fewer and does not contain nested hashes, prefer a single-line style, for example:
641
739
  - `json data: { type: String, required: true }`
642
740
  - `in_query(name: String, value: String)` (prefer `in_xxx(...)` batch declarations over multiple `xx` DSL lines when possible)
741
+ - use `!` but not `required: true`
643
742
  - use `doc_dry`, `scope`, and `transform`、`px(px_key)`、`px.slice` to keep controller concise
644
743
  - when request parameters match model declarations, prefer `.schemas` to keep `doc` concise
645
744
 
@@ -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
@@ -6,12 +6,12 @@ module ActionSpec
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  class_methods do
9
- def schemas(only: nil)
9
+ def schemas(only: nil, bang: true)
10
10
  names = selected_column_names(only)
11
11
  @action_spec_validator_index = build_validator_index
12
12
 
13
13
  names.each_with_object(ActiveSupport::OrderedHash.new) do |name, hash|
14
- hash[output_name(name)] = schema_definition_for(name)
14
+ hash[output_name(name, bang:)] = schema_definition_for(name, bang:)
15
15
  end
16
16
  ensure
17
17
  remove_instance_variable(:@action_spec_validator_index) if instance_variable_defined?(:@action_spec_validator_index)
@@ -25,12 +25,13 @@ module ActionSpec
25
25
  column_names.select { |name| selected.include?(name) }
26
26
  end
27
27
 
28
- def output_name(name)
29
- required_attribute?(name) ? "#{name}!" : name
28
+ def output_name(name, bang:)
29
+ bang && required_attribute?(name) ? "#{name}!" : name
30
30
  end
31
31
 
32
- def schema_definition_for(name)
32
+ def schema_definition_for(name, bang:)
33
33
  definition = { type: schema_type_for(name) }
34
+ definition[:required] = true if required_attribute?(name) && !bang
34
35
  definition[:default] = column_default_for(name) unless column_default_for(name).nil?
35
36
  definition[:desc] = column_comment_for(name) if column_comment_for(name).present?
36
37
  definition[:enum] = resolved_enum_for(name) if resolved_enum_for(name).present?
@@ -26,6 +26,10 @@ module ActionSpec
26
26
  def copy
27
27
  self.class.new(item.copy, default:, enum:, range:, pattern:, length:, blank:, desc: description, example:, examples:)
28
28
  end
29
+
30
+ def custom_validation?
31
+ item.custom_validation?
32
+ end
29
33
  end
30
34
  end
31
35
  end
@@ -40,6 +40,10 @@ module ActionSpec
40
40
  raise NotImplementedError
41
41
  end
42
42
 
43
+ def custom_validation?
44
+ false
45
+ end
46
+
43
47
  private
44
48
 
45
49
  def add_error(result, path, type, **options)
@@ -3,13 +3,14 @@
3
3
  module ActionSpec
4
4
  module Schema
5
5
  class Field
6
- attr_reader :name, :schema, :transform, :px_key, :scopes
6
+ attr_reader :name, :schema, :transform, :validate, :px_key, :scopes
7
7
 
8
- def initialize(name:, required:, schema:, transform: nil, px_key: nil, scopes: [])
8
+ def initialize(name:, required:, schema:, transform: nil, validate: nil, px_key: nil, scopes: [])
9
9
  @name = name.to_sym
10
10
  @required = required
11
11
  @schema = schema
12
12
  @transform = transform
13
+ @validate = validate
13
14
  @px_key = px_key&.to_sym
14
15
  @scopes = Array(scopes).map(&:to_sym).freeze
15
16
  end
@@ -36,8 +37,21 @@ module ActionSpec
36
37
  end
37
38
  end
38
39
 
40
+ def validate_value(value, context: nil)
41
+ return true if validate.nil? || value.equal?(ActionSpec::Schema::Missing)
42
+
43
+ case validate
44
+ when Proc then apply_validation_proc(value, context:)
45
+ else true
46
+ end
47
+ end
48
+
49
+ def custom_validation?
50
+ validate.present? || schema.custom_validation?
51
+ end
52
+
39
53
  def copy
40
- self.class.new(name:, required: required?, schema: schema.copy, transform:, px_key:, scopes:)
54
+ self.class.new(name:, required: required?, schema: schema.copy, transform:, validate:, px_key:, scopes:)
41
55
  end
42
56
 
43
57
  private
@@ -58,6 +72,14 @@ module ActionSpec
58
72
  transform.call(value)
59
73
  end
60
74
 
75
+ def apply_validation_proc(value, context:)
76
+ return context.instance_exec(&validate) if context && validate.arity.zero?
77
+ return context.instance_exec(value, &validate) if context && (validate.arity == 1 || validate.arity.negative?)
78
+ return validate.call if validate.arity.zero?
79
+
80
+ validate.call(value)
81
+ end
82
+
61
83
  def invoke_context_transform(context, symbol, value)
62
84
  method = context.method(symbol)
63
85
  return context.public_send(symbol) if method.arity.zero?
@@ -37,6 +37,10 @@ module ActionSpec
37
37
  self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, length:, blank:, desc: description, example:, examples:)
38
38
  end
39
39
 
40
+ def custom_validation?
41
+ fields.any? { |_name, field| field.custom_validation? }
42
+ end
43
+
40
44
  private
41
45
 
42
46
  def normalize_source(value, result:, path:)
@@ -13,7 +13,7 @@ module ActionSpec
13
13
  module Schema
14
14
  Missing = Object.new.freeze
15
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
+ FIELD_OPTION_KEYS = (OPTION_KEYS + %i[required transform px px_key validate]).freeze
17
17
 
18
18
  class << self
19
19
  def build(type = nil, **options)
@@ -28,6 +28,7 @@ module ActionSpec
28
28
  required: required_key?(name) || required || explicit_required?(definition),
29
29
  schema: build_field_schema(strip_field_options(definition)),
30
30
  transform: explicit_transform(definition),
31
+ validate: explicit_validate(definition),
31
32
  px_key: explicit_px_key(definition),
32
33
  scopes:
33
34
  )
@@ -42,9 +43,17 @@ module ActionSpec
42
43
  definition = definition.deep_symbolize_keys
43
44
  if definition.key?(:type)
44
45
  type = definition[:type]
45
- options = definition.except(:type)
46
+ options = definition.slice(*OPTION_KEYS)
46
47
  return ArrayOf.new(from_definition(type: type.first), options) if type.is_a?(Array) && type.one?
47
- return ObjectOf.new(build_fields(definition.except(:type, *OPTION_KEYS))) if type == Object && definition.except(:type, *OPTION_KEYS).present?
48
+ return ArrayOf.new(from_definition(type: nil), options) if type == []
49
+ if type.is_a?(Hash)
50
+ return Scalar.new(Object, options) if type.empty?
51
+
52
+ return ObjectOf.new(build_fields(type), options)
53
+ end
54
+ if [Object, Hash].include?(type) && definition.except(:type, *OPTION_KEYS).present?
55
+ return ObjectOf.new(build_fields(definition.except(:type, *OPTION_KEYS)), options)
56
+ end
48
57
 
49
58
  return Scalar.new(type, options)
50
59
  end
@@ -108,6 +117,10 @@ module ActionSpec
108
117
  definition.is_a?(Hash) ? definition.symbolize_keys[:transform] : nil
109
118
  end
110
119
 
120
+ def explicit_validate(definition)
121
+ definition.is_a?(Hash) ? definition.symbolize_keys[:validate] : nil
122
+ end
123
+
111
124
  def explicit_px_key(definition)
112
125
  return unless definition.is_a?(Hash)
113
126
 
@@ -124,7 +137,7 @@ module ActionSpec
124
137
  def strip_field_options(definition)
125
138
  return definition unless definition.is_a?(Hash)
126
139
 
127
- definition.symbolize_keys.except(:required, :transform, :px, :px_key)
140
+ definition.symbolize_keys.except(:required, :transform, :px, :px_key, :validate)
128
141
  end
129
142
  end
130
143
  end
@@ -30,6 +30,16 @@ module ActionSpec
30
30
  Array(scopes).each { |scope_name| scope_bucket(scope_name)[key] = value }
31
31
  end
32
32
 
33
+ def apply_scope_options!(options_by_scope)
34
+ options_by_scope.each do |scope_name, options|
35
+ bucket = px.scope[scope_name]
36
+ next unless bucket
37
+
38
+ bucket.compact! if options[:compact]
39
+ bucket.delete_if { |_key, value| value.blank? } if options[:compact_blank]
40
+ end
41
+ end
42
+
33
43
  def add_error(attribute, type, **options)
34
44
  if (message = ActionSpec.config.message_for(attribute, type, options))
35
45
  errors.add(attribute, message)
@@ -16,6 +16,8 @@ module ActionSpec
16
16
  merge_body!(result)
17
17
  merge_group!(result, endpoint.request.header, source: header_source, location: :headers)
18
18
  merge_group!(result, endpoint.request.cookie, source: cookie_source, location: :cookies)
19
+ result.apply_scope_options!(endpoint.request.scope_options)
20
+ apply_custom_validations!(result)
19
21
  result
20
22
  end
21
23
 
@@ -23,6 +25,14 @@ module ActionSpec
23
25
 
24
26
  attr_reader :endpoint, :controller, :coerce
25
27
 
28
+ BUILT_IN_GROUPS = {
29
+ path: ->(request) { request.path },
30
+ query: ->(request) { request.query },
31
+ body: ->(request) { request.body },
32
+ headers: ->(request) { request.header },
33
+ cookies: ->(request) { request.cookie }
34
+ }.freeze
35
+
26
36
  def merge_body!(result)
27
37
  if endpoint.request.body_required? && body_source.blank?
28
38
  result.add_error("body", :required)
@@ -90,6 +100,100 @@ module ActionSpec
90
100
 
91
101
  field.name
92
102
  end
103
+
104
+ def apply_custom_validations!(result)
105
+ return unless endpoint.request.custom_validation?
106
+
107
+ with_controller_px(result.px) do
108
+ BUILT_IN_GROUPS.each do |location, group_reader|
109
+ validate_group!(
110
+ result,
111
+ group_reader.call(endpoint.request),
112
+ values: result.px.scope.fetch(location),
113
+ location:
114
+ )
115
+ end
116
+ end
117
+ end
118
+
119
+ def validate_group!(result, group, values:, location:)
120
+ return unless group.custom_validation?
121
+
122
+ group.fields.each do |field|
123
+ next unless field.custom_validation?
124
+
125
+ key = storage_key(field, location)
126
+ next unless values.key?(key)
127
+
128
+ validate_field!(field, values[key], result:, path: [field.name])
129
+ end
130
+ end
131
+
132
+ def validate_field!(field, value, result:, path:)
133
+ validate_nested_schema!(field.schema, value, result:, path:)
134
+ return if field.validate_value(value, context: controller)
135
+
136
+ result.add_error(path.join("."), :invalid)
137
+ end
138
+
139
+ def validate_nested_schema!(schema, value, result:, path:)
140
+ return unless schema.custom_validation?
141
+
142
+ case schema
143
+ when ActionSpec::Schema::ObjectOf
144
+ return unless value.is_a?(Hash)
145
+
146
+ source = value.with_indifferent_access
147
+ schema.fields.each_value do |field|
148
+ next unless field.custom_validation?
149
+ next unless source.key?(field.output_name)
150
+
151
+ validate_field!(field, source[field.output_name], result:, path: [*path, field.name])
152
+ end
153
+ when ActionSpec::Schema::ArrayOf
154
+ return unless value.is_a?(Array)
155
+
156
+ value.each_with_index do |entry, index|
157
+ validate_array_item!(schema.item, entry, result:, path: [*path, index])
158
+ end
159
+ end
160
+ end
161
+
162
+ def validate_array_item!(schema, value, result:, path:)
163
+ return unless schema.custom_validation?
164
+
165
+ case schema
166
+ when ActionSpec::Schema::ObjectOf
167
+ return unless value.is_a?(Hash)
168
+
169
+ source = value.with_indifferent_access
170
+ schema.fields.each_value do |field|
171
+ next unless field.custom_validation?
172
+ next unless source.key?(field.output_name)
173
+
174
+ validate_field!(field, source[field.output_name], result:, path: [*path, field.name])
175
+ end
176
+ when ActionSpec::Schema::ArrayOf
177
+ return unless value.is_a?(Array)
178
+
179
+ value.each_with_index do |entry, index|
180
+ validate_array_item!(schema.item, entry, result:, path: [*path, index])
181
+ end
182
+ end
183
+ end
184
+
185
+ def with_controller_px(px)
186
+ previous_defined = controller.instance_variable_defined?(:@px)
187
+ previous = controller.instance_variable_get(:@px)
188
+ controller.instance_variable_set(:@px, px)
189
+ yield
190
+ ensure
191
+ if previous_defined
192
+ controller.instance_variable_set(:@px, previous)
193
+ else
194
+ controller.remove_instance_variable(:@px) if controller.instance_variable_defined?(:@px)
195
+ end
196
+ end
93
197
  end
94
198
  end
95
199
  end
@@ -1,3 +1,3 @@
1
1
  module ActionSpec
2
- VERSION = "1.4.0"
2
+ VERSION = "1.5.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.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhandao