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.
- checksums.yaml +4 -4
- data/.rubocop.yml +15 -6
- data/CHANGELOG.md +59 -1
- data/CODE_OF_CONDUCT.md +1 -1
- data/LICENSE.txt +1 -1
- data/README.md +674 -20
- data/Rakefile +26 -3
- data/lib/versponse/base/class_methods.rb +180 -0
- data/lib/versponse/base/dsl.rb +231 -0
- data/lib/versponse/base.rb +35 -0
- data/lib/versponse/builder.rb +423 -0
- data/lib/versponse/cache.rb +47 -0
- data/lib/versponse/callable.rb +74 -0
- data/lib/versponse/class_policies.rb +15 -0
- data/lib/versponse/collapse_if_plan.rb +26 -0
- data/lib/versponse/collections.rb +49 -0
- data/lib/versponse/components.rb +37 -0
- data/lib/versponse/condition.rb +33 -0
- data/lib/versponse/configuration.rb +261 -0
- data/lib/versponse/const_as_enum.rb +39 -0
- data/lib/versponse/context_override/call.rb +27 -0
- data/lib/versponse/context_override/merge.rb +23 -0
- data/lib/versponse/context_override.rb +25 -0
- data/lib/versponse/context_plan.rb +24 -0
- data/lib/versponse/deep_copy.rb +27 -0
- data/lib/versponse/deep_freeze.rb +20 -0
- data/lib/versponse/discriminated_plan.rb +26 -0
- data/lib/versponse/error_policy/recovery.rb +68 -0
- data/lib/versponse/error_policy.rb +97 -0
- data/lib/versponse/expansion.rb +46 -0
- data/lib/versponse/extractors/block.rb +24 -0
- data/lib/versponse/extractors/fallback.rb +27 -0
- data/lib/versponse/extractors/itself.rb +21 -0
- data/lib/versponse/extractors/key.rb +28 -0
- data/lib/versponse/extractors/path.rb +28 -0
- data/lib/versponse/extractors.rb +23 -0
- data/lib/versponse/field_order.rb +23 -0
- data/lib/versponse/field_type.rb +88 -0
- data/lib/versponse/formatters.rb +38 -0
- data/lib/versponse/gem_version.rb +6 -0
- data/lib/versponse/inline_validation/builder.rb +129 -0
- data/lib/versponse/inline_validation/state.rb +16 -0
- data/lib/versponse/inline_validation.rb +86 -0
- data/lib/versponse/internals.rb +553 -0
- data/lib/versponse/json_value.rb +22 -0
- data/lib/versponse/lazy_plan.rb +30 -0
- data/lib/versponse/naming.rb +68 -0
- data/lib/versponse/object_schema.rb +25 -0
- data/lib/versponse/open_api/builder.rb +159 -0
- data/lib/versponse/open_api/component_set.rb +33 -0
- data/lib/versponse/open_api/declarations.rb +90 -0
- data/lib/versponse/open_api/document.rb +187 -0
- data/lib/versponse/open_api/ref_check.rb +89 -0
- data/lib/versponse/open_api/rswag_specs.rb +98 -0
- data/lib/versponse/open_api/sources.rb +72 -0
- data/lib/versponse/open_api/values.rb +29 -0
- data/lib/versponse/open_api.rb +12 -0
- data/lib/versponse/output_validator.rb +82 -0
- data/lib/versponse/pattern.rb +38 -0
- data/lib/versponse/plan.rb +104 -0
- data/lib/versponse/properties/array.rb +56 -0
- data/lib/versponse/properties/base.rb +325 -0
- data/lib/versponse/properties/collection.rb +135 -0
- data/lib/versponse/properties/const.rb +46 -0
- data/lib/versponse/properties/container.rb +51 -0
- data/lib/versponse/properties/enum.rb +93 -0
- data/lib/versponse/properties/field.rb +43 -0
- data/lib/versponse/properties/item_one_of.rb +19 -0
- data/lib/versponse/properties/object.rb +32 -0
- data/lib/versponse/properties/one_of.rb +176 -0
- data/lib/versponse/properties/reference.rb +133 -0
- data/lib/versponse/references.rb +162 -0
- data/lib/versponse/registry.rb +124 -0
- data/lib/versponse/schema_context.rb +69 -0
- data/lib/versponse/schema_merge.rb +24 -0
- data/lib/versponse/selection/scope.rb +36 -0
- data/lib/versponse/selection.rb +92 -0
- data/lib/versponse/selection_schema.rb +101 -0
- data/lib/versponse/steps/checked.rb +43 -0
- data/lib/versponse/steps/checked_read.rb +46 -0
- data/lib/versponse/steps/checked_read_formatted.rb +32 -0
- data/lib/versponse/steps/checked_value.rb +48 -0
- data/lib/versponse/steps/const.rb +44 -0
- data/lib/versponse/steps/nested.rb +98 -0
- data/lib/versponse/steps/one_of.rb +62 -0
- data/lib/versponse/steps/read.rb +48 -0
- data/lib/versponse/steps/read_formatted.rb +28 -0
- data/lib/versponse/steps/value.rb +52 -0
- data/lib/versponse/supported_versions.rb +61 -0
- data/lib/versponse/validator/all.rb +32 -0
- data/lib/versponse/validator/array_check.rb +48 -0
- data/lib/versponse/validator/boolean_check.rb +16 -0
- data/lib/versponse/validator/both.rb +24 -0
- data/lib/versponse/validator/class_check.rb +25 -0
- data/lib/versponse/validator/closed_object_check.rb +78 -0
- data/lib/versponse/validator/combinator.rb +40 -0
- data/lib/versponse/validator/compiler.rb +146 -0
- data/lib/versponse/validator/constant.rb +33 -0
- data/lib/versponse/validator/date_time_check.rb +21 -0
- data/lib/versponse/validator/format_check.rb +49 -0
- data/lib/versponse/validator/integer_check.rb +16 -0
- data/lib/versponse/validator/nullable_check.rb +22 -0
- data/lib/versponse/validator/number_check.rb +58 -0
- data/lib/versponse/validator/object_check.rb +90 -0
- data/lib/versponse/validator/ref.rb +38 -0
- data/lib/versponse/validator/root.rb +31 -0
- data/lib/versponse/validator/string_check.rb +48 -0
- data/lib/versponse/validator/string_keys.rb +21 -0
- data/lib/versponse/validator/type_check.rb +84 -0
- data/lib/versponse/validator/value_check.rb +36 -0
- data/lib/versponse/validator.rb +10 -0
- data/lib/versponse/variant_dispatcher.rb +35 -0
- data/lib/versponse/version.rb +179 -1
- data/lib/versponse/version_resolver.rb +27 -0
- data/lib/versponse/versions.rb +116 -0
- data/lib/versponse/view.rb +126 -0
- data/lib/versponse/wrapper.rb +53 -0
- data/lib/versponse.rb +165 -3
- metadata +151 -12
- 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
|