versponse 0.1.0 → 0.2.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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -6
  3. data/CHANGELOG.md +59 -1
  4. data/CODE_OF_CONDUCT.md +1 -1
  5. data/LICENSE.txt +1 -1
  6. data/README.md +674 -20
  7. data/Rakefile +26 -3
  8. data/lib/versponse/base/class_methods.rb +180 -0
  9. data/lib/versponse/base/dsl.rb +231 -0
  10. data/lib/versponse/base.rb +35 -0
  11. data/lib/versponse/builder.rb +423 -0
  12. data/lib/versponse/cache.rb +47 -0
  13. data/lib/versponse/callable.rb +74 -0
  14. data/lib/versponse/class_policies.rb +15 -0
  15. data/lib/versponse/collapse_if_plan.rb +26 -0
  16. data/lib/versponse/collections.rb +49 -0
  17. data/lib/versponse/components.rb +37 -0
  18. data/lib/versponse/condition.rb +33 -0
  19. data/lib/versponse/configuration.rb +261 -0
  20. data/lib/versponse/const_as_enum.rb +39 -0
  21. data/lib/versponse/context_override/call.rb +27 -0
  22. data/lib/versponse/context_override/merge.rb +23 -0
  23. data/lib/versponse/context_override.rb +25 -0
  24. data/lib/versponse/context_plan.rb +24 -0
  25. data/lib/versponse/deep_copy.rb +27 -0
  26. data/lib/versponse/deep_freeze.rb +20 -0
  27. data/lib/versponse/discriminated_plan.rb +26 -0
  28. data/lib/versponse/error_policy/recovery.rb +68 -0
  29. data/lib/versponse/error_policy.rb +97 -0
  30. data/lib/versponse/expansion.rb +46 -0
  31. data/lib/versponse/extractors/block.rb +24 -0
  32. data/lib/versponse/extractors/fallback.rb +27 -0
  33. data/lib/versponse/extractors/itself.rb +21 -0
  34. data/lib/versponse/extractors/key.rb +28 -0
  35. data/lib/versponse/extractors/path.rb +28 -0
  36. data/lib/versponse/extractors.rb +23 -0
  37. data/lib/versponse/field_order.rb +23 -0
  38. data/lib/versponse/field_type.rb +88 -0
  39. data/lib/versponse/formatters.rb +38 -0
  40. data/lib/versponse/gem_version.rb +6 -0
  41. data/lib/versponse/inline_validation/builder.rb +129 -0
  42. data/lib/versponse/inline_validation/state.rb +16 -0
  43. data/lib/versponse/inline_validation.rb +86 -0
  44. data/lib/versponse/internals.rb +553 -0
  45. data/lib/versponse/json_value.rb +22 -0
  46. data/lib/versponse/lazy_plan.rb +30 -0
  47. data/lib/versponse/naming.rb +68 -0
  48. data/lib/versponse/object_schema.rb +25 -0
  49. data/lib/versponse/open_api/builder.rb +159 -0
  50. data/lib/versponse/open_api/component_set.rb +33 -0
  51. data/lib/versponse/open_api/declarations.rb +90 -0
  52. data/lib/versponse/open_api/document.rb +187 -0
  53. data/lib/versponse/open_api/ref_check.rb +89 -0
  54. data/lib/versponse/open_api/rswag_specs.rb +98 -0
  55. data/lib/versponse/open_api/sources.rb +72 -0
  56. data/lib/versponse/open_api/values.rb +29 -0
  57. data/lib/versponse/open_api.rb +12 -0
  58. data/lib/versponse/output_validator.rb +82 -0
  59. data/lib/versponse/pattern.rb +38 -0
  60. data/lib/versponse/plan.rb +104 -0
  61. data/lib/versponse/properties/array.rb +56 -0
  62. data/lib/versponse/properties/base.rb +325 -0
  63. data/lib/versponse/properties/collection.rb +135 -0
  64. data/lib/versponse/properties/const.rb +46 -0
  65. data/lib/versponse/properties/container.rb +51 -0
  66. data/lib/versponse/properties/enum.rb +93 -0
  67. data/lib/versponse/properties/field.rb +43 -0
  68. data/lib/versponse/properties/item_one_of.rb +19 -0
  69. data/lib/versponse/properties/object.rb +32 -0
  70. data/lib/versponse/properties/one_of.rb +176 -0
  71. data/lib/versponse/properties/reference.rb +133 -0
  72. data/lib/versponse/references.rb +162 -0
  73. data/lib/versponse/registry.rb +124 -0
  74. data/lib/versponse/schema_context.rb +69 -0
  75. data/lib/versponse/schema_merge.rb +24 -0
  76. data/lib/versponse/selection/scope.rb +36 -0
  77. data/lib/versponse/selection.rb +92 -0
  78. data/lib/versponse/selection_schema.rb +101 -0
  79. data/lib/versponse/steps/checked.rb +43 -0
  80. data/lib/versponse/steps/checked_read.rb +46 -0
  81. data/lib/versponse/steps/checked_read_formatted.rb +32 -0
  82. data/lib/versponse/steps/checked_value.rb +48 -0
  83. data/lib/versponse/steps/const.rb +44 -0
  84. data/lib/versponse/steps/nested.rb +98 -0
  85. data/lib/versponse/steps/one_of.rb +62 -0
  86. data/lib/versponse/steps/read.rb +48 -0
  87. data/lib/versponse/steps/read_formatted.rb +28 -0
  88. data/lib/versponse/steps/value.rb +52 -0
  89. data/lib/versponse/supported_versions.rb +61 -0
  90. data/lib/versponse/validator/all.rb +32 -0
  91. data/lib/versponse/validator/array_check.rb +48 -0
  92. data/lib/versponse/validator/boolean_check.rb +16 -0
  93. data/lib/versponse/validator/both.rb +24 -0
  94. data/lib/versponse/validator/class_check.rb +25 -0
  95. data/lib/versponse/validator/closed_object_check.rb +78 -0
  96. data/lib/versponse/validator/combinator.rb +40 -0
  97. data/lib/versponse/validator/compiler.rb +146 -0
  98. data/lib/versponse/validator/constant.rb +33 -0
  99. data/lib/versponse/validator/date_time_check.rb +21 -0
  100. data/lib/versponse/validator/format_check.rb +49 -0
  101. data/lib/versponse/validator/integer_check.rb +16 -0
  102. data/lib/versponse/validator/nullable_check.rb +22 -0
  103. data/lib/versponse/validator/number_check.rb +58 -0
  104. data/lib/versponse/validator/object_check.rb +90 -0
  105. data/lib/versponse/validator/ref.rb +38 -0
  106. data/lib/versponse/validator/root.rb +31 -0
  107. data/lib/versponse/validator/string_check.rb +48 -0
  108. data/lib/versponse/validator/string_keys.rb +21 -0
  109. data/lib/versponse/validator/type_check.rb +84 -0
  110. data/lib/versponse/validator/value_check.rb +36 -0
  111. data/lib/versponse/validator.rb +10 -0
  112. data/lib/versponse/variant_dispatcher.rb +35 -0
  113. data/lib/versponse/version.rb +179 -1
  114. data/lib/versponse/version_resolver.rb +27 -0
  115. data/lib/versponse/versions.rb +116 -0
  116. data/lib/versponse/view.rb +126 -0
  117. data/lib/versponse/wrapper.rb +53 -0
  118. data/lib/versponse.rb +165 -3
  119. metadata +151 -12
  120. data/sig/versponse.rbs +0 -4
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module OpenAPI
5
+ # Finds the classes that responses and requests declarations name, and raises when they name nothing usable
6
+ #
7
+ # @api private
8
+ module Sources
9
+ # Checks an explicit list when it's declared
10
+ #
11
+ # @param keyword [Symbol] :responses or :requests
12
+ # @param classes [Array<Class>] listed classes
13
+ # @return [void]
14
+ # @raise [Versponse::DefinitionError] for a class of the wrong kind, or a request that isn't a body request
15
+ def self.check!(keyword, classes)
16
+ classes.each { |klass| (keyword == :responses) ? check_response!(klass) : check_request!(klass) }
17
+ end
18
+
19
+ # Resolves the namespace, eager-loads it through Zeitwerk, and selects its classes
20
+ #
21
+ # @param keyword [Symbol] :responses or :requests
22
+ # @param namespace [Module, String] declared namespace
23
+ # @param kind [String] what the namespace should contain, for the error message
24
+ # @yieldreturn [Array<Class>] loaded candidate classes, read after eager loading
25
+ # @return [Array<Class>] the namespace's classes, sorted by name
26
+ # @raise [Versponse::DefinitionError] when the namespace isn't defined, isn't a module, or contains no candidates
27
+ def self.in_namespace(keyword, namespace, kind)
28
+ mod = namespace_module(keyword, namespace)
29
+ Zeitwerk::Loader.eager_load_namespace(mod)
30
+ found = yield.select { |klass| klass.name&.start_with?("#{mod.name}::") }.sort_by(&:name)
31
+ raise DefinitionError, "#{keyword} namespace: #{mod.name} matched no #{kind}" if found.empty?
32
+
33
+ found
34
+ end
35
+
36
+ # @param klass [Class] verquest request
37
+ # @return [Boolean] true for a request the document lists as a component
38
+ def self.body?(klass)
39
+ !klass.respond_to?(:location) || klass.location == :body
40
+ end
41
+
42
+ def self.check_response!(klass)
43
+ raise DefinitionError, "responses takes Versponse::Base subclasses, got #{klass.inspect}" unless References.versponse?(klass)
44
+ end
45
+
46
+ def self.check_request!(klass)
47
+ raise DefinitionError, "requests takes Verquest::Base subclasses, got #{klass.inspect}" unless verquest?(klass)
48
+ raise DefinitionError, "requests: #{klass} is a :#{klass.location} request; only body requests are components" unless body?(klass)
49
+ end
50
+
51
+ def self.verquest?(klass)
52
+ defined?(::Verquest::Base) && klass.is_a?(Class) && klass < ::Verquest::Base
53
+ end
54
+
55
+ def self.namespace_module(keyword, namespace)
56
+ return namespace if namespace.is_a?(Module)
57
+
58
+ mod = Object.const_get(namespace)
59
+ raise DefinitionError, "#{keyword} namespace: #{namespace} is not a module" unless mod.is_a?(Module)
60
+
61
+ mod
62
+ rescue NameError => e
63
+ # Only a missing segment of the path itself; a NameError raised while autoloading it is a bug to surface
64
+ raise unless e.instance_of?(NameError) && namespace.to_s.split("::").include?(e.name.to_s)
65
+
66
+ raise DefinitionError, "#{keyword} namespace: #{namespace} is not defined"
67
+ end
68
+
69
+ private_class_method :check_response!, :check_request!, :verquest?, :namespace_module
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module OpenAPI
5
+ # Resolves declared values: blocks are called (with the version when they take an argument)
6
+ # and Hash keys become Strings, never camelized
7
+ #
8
+ # @api private
9
+ module Values
10
+ # @param value [Object, Proc, nil] declared value or block
11
+ # @param version [String] version being built
12
+ # @return [Object, nil] the resolved, String-keyed value
13
+ def self.resolve(value, version)
14
+ value = value.arity.zero? ? value.call : value.call(version) if value.is_a?(Proc)
15
+ stringify(value)
16
+ end
17
+
18
+ # @param value [Object] Hash, Array or scalar
19
+ # @return [Object] the value with String Hash keys at every level
20
+ def self.stringify(value)
21
+ case value
22
+ when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
23
+ when Array then value.map { |item| stringify(item) }
24
+ else value
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Builds OpenAPI documents from response and request classes, for rswag and other tools
5
+ module OpenAPI
6
+ # @param documents [Array<Class>] Versponse::OpenAPI::Document subclasses
7
+ # @return [Versponse::OpenAPI::RswagSpecs] a drop-in value for rswag's config.openapi_specs
8
+ def self.rswag_specs(*documents)
9
+ RswagSpecs.new(documents)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Validates rendered output against its validation schema (config.validate_output)
5
+ #
6
+ # @api private
7
+ module OutputValidator
8
+ # Errors the message names; the rest are counted
9
+ MESSAGE_ERRORS = 2
10
+
11
+ # @param klass [Class] response class
12
+ # @param data [Hash, Array<Hash>] rendered output, before root/meta wrapping
13
+ # @param version [String] requested version name
14
+ # @param view [Symbol] view name
15
+ # @param selection [Versponse::Selection] the render's selection; a selected render is checked against the
16
+ # pruned validation schema
17
+ # @return [void]
18
+ # @raise [Versponse::InvalidOutputError] when the output does not match
19
+ def self.call(klass, data, version:, view:, selection: Selection::ALL)
20
+ checker, schemer = selection.all? ? [klass.versponse.checker(version:, view:), nil] : klass.versponse.selected_validators(version:, view:, selection:)
21
+ return if valid?(checker, data)
22
+
23
+ schemer ||= klass.versponse.schemer(version:, view:)
24
+ errors = if data.is_a?(Array)
25
+ data.each_with_index.flat_map { |item, index| errors_for(schemer, item, "/#{index}") }
26
+ else
27
+ errors_for(schemer, data, "")
28
+ end
29
+ return if errors.empty?
30
+
31
+ raise InvalidOutputError.new(error_message(klass, version, view, errors), errors:)
32
+ end
33
+
34
+ # Whether this render is validated: validate_output must allow it (true, or a callable returning truthy for
35
+ # the class), and the render must be sampled; the cheap checks run first, so a callable runs only on sampled renders
36
+ #
37
+ # @param klass [Class] response class serialize was called on
38
+ # @return [Boolean] true when the render is validated
39
+ def self.enabled?(klass)
40
+ config = Versponse.configuration
41
+ setting = config.validate_output
42
+ return false unless setting
43
+
44
+ rate = config.validate_output_sample_rate
45
+ return false if rate < 1 && Random.rand >= rate
46
+
47
+ setting == true || !!setting.call(klass)
48
+ end
49
+
50
+ # The compiled checker is the fast path; json_schemer runs only when it fails or is missing
51
+ #
52
+ # @param checker [#valid?, nil] compiled checker of the schema
53
+ # @param data [Hash, Array<Hash>] rendered output
54
+ # @return [Boolean] true when the checker accepts every item
55
+ def self.valid?(checker, data)
56
+ return false unless checker
57
+
58
+ data.is_a?(Array) ? data.all? { |item| checker.valid?(item) } : checker.valid?(data)
59
+ end
60
+
61
+ # @param schemer [JSONSchemer::Schema] compiled schema
62
+ # @param data [Hash] one rendered object
63
+ # @param prefix [String] pointer prefix for collection items
64
+ # @return [Array<Hash>] errors with :pointer, :type, :message and :details; messages point into the collection
65
+ def self.errors_for(schemer, data, prefix)
66
+ schemer.validate(data).map do |error|
67
+ pointer = prefix + error["data_pointer"]
68
+ # An item is validated on its own, so its message points inside the item
69
+ message = prefix.empty? ? error["error"] : error["error"].sub("`#{error["data_pointer"]}`", "`#{pointer}`")
70
+ {pointer:, type: error["type"], message:, details: error["details"]}
71
+ end
72
+ end
73
+
74
+ # json_schemer's messages name their pointer
75
+ def self.error_message(klass, version, view, errors)
76
+ details = errors.first(MESSAGE_ERRORS).map { |error| error[:message] }
77
+ details << "and #{errors.size - MESSAGE_ERRORS} more" if errors.size > MESSAGE_ERRORS
78
+ "#{klass.name || "AnonymousResponse"} output does not match its #{version}/#{view} schema: #{details.join("; ")}"
79
+ end
80
+ private_class_method :error_message
81
+ end
82
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Writes a Regexp pattern: as an ECMA-262 String, the dialect JSON Schema patterns use
5
+ #
6
+ # @api private
7
+ module Pattern
8
+ # Regexp options ECMA-262 patterns have no inline form for
9
+ OPTIONS = Regexp::IGNORECASE | Regexp::EXTENDED | Regexp::MULTILINE
10
+
11
+ # An escape, a bracket expression (kept whole: \A inside one is a literal A), or an inline option group
12
+ TOKEN = /\\.|\[(?:\\.|[^\\\]])*\]|\(\?-?[imx]+(?:-[imx]*)?[:)]/m
13
+
14
+ # @param pattern [Regexp, String] pattern as declared
15
+ # @return [String] the String as given, or the Regexp's source with \A and \z written as ^ and $
16
+ # @raise [Versponse::DefinitionError] for a Regexp with options (flags or inline groups), or one using \Z
17
+ def self.source(pattern)
18
+ return pattern unless pattern.is_a?(Regexp)
19
+ raise options_error(pattern) unless (pattern.options & OPTIONS).zero?
20
+
21
+ # Tokens are matched from the left, so an escaped backslash before an A stays a literal
22
+ pattern.source.gsub(TOKEN) do |token|
23
+ case token
24
+ when "\\A" then "^"
25
+ when "\\z" then "$"
26
+ when "\\Z" then raise DefinitionError, "pattern: #{pattern.inspect} uses \\Z, which ECMA-262 has no equivalent for; use \\z"
27
+ when /\A\(\?/ then raise options_error(pattern)
28
+ else token
29
+ end
30
+ end
31
+ end
32
+
33
+ def self.options_error(pattern)
34
+ DefinitionError.new("pattern: #{pattern.inspect} has options, which ECMA-262 patterns can't carry; use a String pattern")
35
+ end
36
+ private_class_method :options_error
37
+ end
38
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Compiled, frozen list of steps that turns one object into a Hash
5
+ #
6
+ # @api private
7
+ class Plan
8
+ # @return [String] label used in extraction errors, like "UserResponse[2025-09/default]"
9
+ attr_reader :label
10
+ # @return [Array<#call>] compiled steps
11
+ attr_reader :steps
12
+ # @return [Array<Versponse::ErrorPolicy::Recovery, nil>, nil] recovery per step, nil when every step raises
13
+ attr_reader :recoveries
14
+ # @return [Array<String>] keys rendered whatever a selection says: identifiers and consts
15
+ attr_reader :always
16
+
17
+ # @param label [String] plan label
18
+ # @param steps [Array<#call>] compiled steps
19
+ # @param recoveries [Array<Versponse::ErrorPolicy::Recovery, nil>, nil] recovery per step
20
+ # @param always [Array<String>] identifier keys (consts are found among the steps)
21
+ def initialize(label:, steps:, recoveries: nil, always: [])
22
+ @label = label
23
+ @steps = steps.dup.freeze
24
+ @recoveries = recoveries
25
+ @always = (always + @steps.grep(Steps::Const).map(&:key)).uniq.freeze
26
+ freeze
27
+ end
28
+
29
+ # A step whose recovery accepts its error is recovered and the loop goes on with the next step (retry restarts
30
+ # the begin block only, so output and index keep their values). An error from the recovery itself (a handler or
31
+ # the hook) is raised from the rescue clause, outside the begin block, so it propagates.
32
+ #
33
+ # @param object [Object] source object or Hash
34
+ # @param context [Hash] frozen serialize context
35
+ # @param expansion [Versponse::Expansion, Versponse::Selection::Scope] expand tree node for this object, paired with
36
+ # its selection in a selected render
37
+ # @return [Hash{String => Object}] rendered object
38
+ # @raise [Versponse::ExtractionError] when a step raises NoMethodError or KeyError that no policy recovers (the
39
+ # original error is kept as `cause`), unless an on_recovered_error hook raised it, which propagates as raised;
40
+ # other errors no policy recovers propagate unchanged
41
+ def call(object, context, expansion = Expansion::NONE)
42
+ return call_selected(object, context, expansion) if Selection::Scope === expansion
43
+
44
+ output = {}
45
+ steps = @steps
46
+ index = 0
47
+ hash = Hash === object
48
+ begin
49
+ while index < steps.size
50
+ steps[index].call(object, hash, context, output, expansion)
51
+ index += 1
52
+ end
53
+ rescue => e
54
+ recover!(e, steps[index], index, object, context, output)
55
+ index += 1
56
+ retry
57
+ end
58
+ output
59
+ end
60
+
61
+ private
62
+
63
+ # A selected render walks the step indexes its selection memoizes for this plan
64
+ def call_selected(object, context, scope)
65
+ output = {}
66
+ steps = @steps
67
+ indexes = scope.selection.indexes_for(self)
68
+ position = 0
69
+ hash = Hash === object
70
+ begin
71
+ while position < indexes.size
72
+ steps[indexes[position]].call(object, hash, context, output, scope)
73
+ position += 1
74
+ end
75
+ rescue => e
76
+ index = indexes[position]
77
+ recover!(e, steps[index], index, object, context, output)
78
+ position += 1
79
+ retry
80
+ end
81
+ output
82
+ end
83
+
84
+ # Recovers the step's error with its policy, or raises it (as an ExtractionError for NoMethodError and KeyError,
85
+ # except a hook's error)
86
+ def recover!(error, step, index, object, context, output)
87
+ recovery = @recoveries&.[](index)
88
+ raise extraction_error(error, step) unless recovery&.recovers?(error)
89
+
90
+ recovery.recover(error, object, context, output)
91
+ end
92
+
93
+ # An error the on_recovered_error hook raised keeps its class and mark, so no parent policy recovers it
94
+ def extraction_error(error, step)
95
+ return error if ErrorPolicy.unrecoverable?(error)
96
+
97
+ case error
98
+ when ExtractionError then ExtractionError.new(label:, keys: [step&.key, *error.keys].compact, detail: error.detail)
99
+ when NoMethodError, KeyError then ExtractionError.new(label:, keys: [step&.key].compact, detail: error.message)
100
+ else error
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Properties
5
+ # An array of scalar values
6
+ #
7
+ # @api private
8
+ class Array < Base
9
+ # @return [Array<String>] keywords this property type writes itself
10
+ def self.written_keywords = %w[items].freeze
11
+
12
+ # @return [Boolean] true: nil values can be replaced
13
+ def self.default_option? = true
14
+
15
+ # @param type [Symbol, String, Array<Symbol, String>] item type: built-in ({FieldType::BUILT_IN}), custom, or a union of built-in types
16
+ # @param item_schema_options [Hash] JSON Schema keywords of each item; they win over the custom type's
17
+ # @param options [Hash] see {Base#initialize}
18
+ # @raise [Versponse::DefinitionError] for an unknown item type or an invalid union
19
+ def initialize(type:, item_schema_options: {}, **options)
20
+ super(**options)
21
+ check_keywords!(item_schema_options, label: "#{key} item_schema_options", written_keywords: [])
22
+ @item_type, type_options = FieldType.resolve(type, key)
23
+ @item_schema_options = type_options.merge(Naming.camelize_keys(item_schema_options))
24
+ check_default!
25
+ end
26
+
27
+ # @param context [Versponse::SchemaContext, nil] schema generation context
28
+ # @return [Hash] JSON Schema of the array; a union item type is an anyOf in OpenAPI 3.0 documents
29
+ # (an allOf with the items' own anyOf, when item_schema_options declare one)
30
+ def schema(context)
31
+ nullable_type({"type" => "array", "items" => items_schema(context)}.merge(schema_options), context)
32
+ end
33
+
34
+ # @param version [String] unused by scalars
35
+ # @param formatters [Hash{String => #call}] formatters by JSON Schema format, for the compiling class and version
36
+ # @param policies [Versponse::ClassPolicies] class-level on_nil and on_error of the compiling class
37
+ # @return [Versponse::Steps::Value] compiled step; like a collection, the value is read with {Collections.items}
38
+ # (to_ary values and Sets; anything else raises) into a new Array, and items are formatted by their format
39
+ def step(version:, formatters:, policies: ClassPolicies::NONE)
40
+ formatter = formatters[@item_schema_options["format"]]
41
+ # The source's Array (or a relation's loaded records) is copied, so changing the output leaves it alone
42
+ return value_step(formatter: ->(items) { Collections.items(items).dup }, policies:) unless formatter
43
+
44
+ value_step(formatter: ->(items) { Collections.items(items).map { |item| item.nil? ? nil : formatter.call(item) } }, policies:)
45
+ end
46
+
47
+ private
48
+
49
+ def items_schema(context)
50
+ return FieldType.openapi30_union(@item_type, @item_schema_options) if openapi30_union?(@item_type, context)
51
+
52
+ {"type" => @item_type}.merge(@item_schema_options)
53
+ end
54
+ end
55
+ end
56
+ end