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,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Compiled if:/unless: pair, called with (object, context)
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class Condition
|
|
8
|
+
# @param if_callable [Proc, nil] emit the key only when this returns truthy
|
|
9
|
+
# @param unless_callable [Proc, nil] emit the key only when this returns falsy
|
|
10
|
+
# @return [Condition, nil] nil when neither is given
|
|
11
|
+
def self.build(if_callable, unless_callable)
|
|
12
|
+
return if if_callable.nil? && unless_callable.nil?
|
|
13
|
+
|
|
14
|
+
new(Callable.normalize(if_callable), Callable.normalize(unless_callable))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param if_callable [Proc, nil] normalized if: callable
|
|
18
|
+
# @param unless_callable [Proc, nil] normalized unless: callable
|
|
19
|
+
def initialize(if_callable, unless_callable)
|
|
20
|
+
@if_callable = if_callable
|
|
21
|
+
@unless_callable = unless_callable
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param object [Object] serialized object
|
|
26
|
+
# @param context [Hash] serialize context
|
|
27
|
+
# @return [Boolean] whether the key is emitted
|
|
28
|
+
def call(object, context)
|
|
29
|
+
(@if_callable.nil? || @if_callable.call(object, context)) &&
|
|
30
|
+
(@unless_callable.nil? || !@unless_callable.call(object, context))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Settings for Versponse, changed with {Versponse.configure}
|
|
5
|
+
#
|
|
6
|
+
# Plans and schemas are cached. Setting json_schema_version, formatters, version_resolver, field_order,
|
|
7
|
+
# default_additional_properties, on_error or recoverable_errors on {Versponse.configuration} resets them;
|
|
8
|
+
# custom_field_types resolve when properties are defined, so set them before response classes load.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# Versponse.configure do |config|
|
|
12
|
+
# config.current_version = -> { Current.api_version }
|
|
13
|
+
# config.validate_output = Rails.env.local?
|
|
14
|
+
# end
|
|
15
|
+
class Configuration
|
|
16
|
+
# Supported JSON Schema versions and their json_schemer modules, the same as verquest's
|
|
17
|
+
SCHEMAS = {
|
|
18
|
+
draft4: :Draft4,
|
|
19
|
+
draft6: :Draft6,
|
|
20
|
+
draft7: :Draft7,
|
|
21
|
+
draft2019_09: :Draft201909,
|
|
22
|
+
draft2020_12: :Draft202012,
|
|
23
|
+
openapi30: :OpenAPI30,
|
|
24
|
+
openapi31: :OpenAPI31
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# JSON Schema versions without the `const` keyword, as in verquest
|
|
28
|
+
VERSIONS_WITHOUT_CONST = %i[draft4 openapi30].freeze
|
|
29
|
+
|
|
30
|
+
# How each JSON Schema version names a schema for "#Name" references: $anchor from 2019-09,
|
|
31
|
+
# a plain-name $id in drafts 6 and 7, and id in draft 4 (which OpenAPI 3.0 is based on)
|
|
32
|
+
ANCHOR_KEYWORDS = {draft4: "id", openapi30: "id", draft6: "$id", draft7: "$id"}.freeze
|
|
33
|
+
|
|
34
|
+
# @return [Boolean, #call] validate every rendered response against its schema, none (default false), or those
|
|
35
|
+
# for which the callable, called with the response class, returns a truthy value
|
|
36
|
+
attr_reader :validate_output
|
|
37
|
+
# @return [Numeric] share of renders validated when validate_output allows it, from 0 to 1 (default 1.0)
|
|
38
|
+
attr_reader :validate_output_sample_rate
|
|
39
|
+
# @return [Boolean] additionalProperties of generated object schemas (default false)
|
|
40
|
+
attr_reader :default_additional_properties
|
|
41
|
+
# @return [#call, nil] returns the version used when serialize gets no version:
|
|
42
|
+
attr_reader :current_version
|
|
43
|
+
# @return [#call] resolves a requested version from the declared ones; it must return the closest
|
|
44
|
+
# declared version at or before the request (it may add rules, e.g. preview sorting highest),
|
|
45
|
+
# because requested versions are canonicalized against all declared names before caching
|
|
46
|
+
attr_reader :version_resolver
|
|
47
|
+
# @return [#call] turns a Hash into a JSON String
|
|
48
|
+
attr_reader :json_generator
|
|
49
|
+
# @return [Hash{String => #call}] formatters keyed by JSON Schema format
|
|
50
|
+
attr_reader :formatters
|
|
51
|
+
# @return [Symbol] JSON Schema version of generated schemas (default :openapi31)
|
|
52
|
+
attr_reader :json_schema_version
|
|
53
|
+
# @return [Symbol] :raise (default) or :ignore for expand paths that match no expandable association
|
|
54
|
+
attr_reader :unknown_expand
|
|
55
|
+
# @return [Symbol] :raise (default) or :ignore for fields: paths that are unknown or below unexpanded associations
|
|
56
|
+
attr_reader :unknown_fields
|
|
57
|
+
# @return [Symbol] :declaration (default) or :alphabetical (identifiers first, then by output key,
|
|
58
|
+
# like blueprinter's sort_fields_by :name_asc)
|
|
59
|
+
attr_reader :field_order
|
|
60
|
+
# @return [Hash{String => Hash}] custom field types, normalized by {FieldType.normalize} (default {})
|
|
61
|
+
attr_reader :custom_field_types
|
|
62
|
+
# @return [Symbol, #call] policy for failing properties without their own or their class's (default :raise)
|
|
63
|
+
attr_reader :on_error
|
|
64
|
+
# @return [Array<Class>] StandardError subclasses a policy recovers (default [StandardError])
|
|
65
|
+
attr_reader :recoverable_errors
|
|
66
|
+
# @return [#call, nil] called with (error, response:, key:, object:) for each error a policy recovers
|
|
67
|
+
attr_reader :on_recovered_error
|
|
68
|
+
|
|
69
|
+
# @return [Configuration] configuration with default settings
|
|
70
|
+
def initialize
|
|
71
|
+
@validate_output = false
|
|
72
|
+
@validate_output_sample_rate = 1.0
|
|
73
|
+
@default_additional_properties = false
|
|
74
|
+
@json_schema_version = :openapi31
|
|
75
|
+
@unknown_expand = :raise
|
|
76
|
+
@unknown_fields = :raise
|
|
77
|
+
@field_order = :declaration
|
|
78
|
+
@version_resolver = VersionResolver
|
|
79
|
+
@json_generator = JSON.method(:generate)
|
|
80
|
+
@formatters = Formatters::DEFAULTS
|
|
81
|
+
@custom_field_types = {}.freeze
|
|
82
|
+
@on_error = :raise
|
|
83
|
+
@recoverable_errors = [StandardError].freeze
|
|
84
|
+
@on_recovered_error = nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param value [Boolean, #call] validate every render, none, or those for which value.call(response_class) is
|
|
88
|
+
# truthy (called per render, e.g. to read Rails' Current)
|
|
89
|
+
# @raise [ArgumentError] for anything else, or a lambda or Method that can't take the response class
|
|
90
|
+
def validate_output=(value)
|
|
91
|
+
@validate_output = (value == true || value == false) ? value : class_callable!(value, :validate_output, "true, false")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param value [Numeric] share of renders validated when validate_output allows it, from 0 to 1
|
|
95
|
+
# @raise [ArgumentError] for anything but a real number from 0 to 1
|
|
96
|
+
def validate_output_sample_rate=(value)
|
|
97
|
+
unless value.is_a?(Numeric) && value.real? && (0..1).cover?(value)
|
|
98
|
+
raise ArgumentError, "validate_output_sample_rate must be a number from 0 to 1"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@validate_output_sample_rate = value
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @param value [Boolean] additionalProperties of generated object schemas
|
|
105
|
+
# @raise [ArgumentError] for a value that isn't true or false
|
|
106
|
+
def default_additional_properties=(value)
|
|
107
|
+
@default_additional_properties = boolean!(value, :default_additional_properties)
|
|
108
|
+
changed!
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param value [#call, nil] returns the current version, or nil to clear it
|
|
112
|
+
# @raise [ArgumentError] when value does not respond to call
|
|
113
|
+
def current_version=(value)
|
|
114
|
+
@current_version = value.nil? ? nil : callable!(value, :current_version)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @param value [#call] called with (requested_version, sorted_versions); must return the closest
|
|
118
|
+
# declared version at or before the request, since requests are canonicalized against all declared names
|
|
119
|
+
# @raise [ArgumentError] when value does not respond to call
|
|
120
|
+
def version_resolver=(value)
|
|
121
|
+
@version_resolver = callable!(value, :version_resolver)
|
|
122
|
+
changed!
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param value [#call] called with the rendered Hash, returns a String
|
|
126
|
+
# @raise [ArgumentError] when value does not respond to call
|
|
127
|
+
def json_generator=(value)
|
|
128
|
+
@json_generator = callable!(value, :json_generator)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @param value [Symbol] one of {SCHEMAS}
|
|
132
|
+
# @raise [ArgumentError] when the version is not supported
|
|
133
|
+
def json_schema_version=(value)
|
|
134
|
+
raise ArgumentError, "Unsupported JSON Schema version: #{value}. Use one of #{SCHEMAS.keys.join(", ")}" unless SCHEMAS.key?(value)
|
|
135
|
+
|
|
136
|
+
@json_schema_version = value
|
|
137
|
+
changed!
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @param value [Symbol] :raise or :ignore
|
|
141
|
+
# @raise [ArgumentError] for any other value
|
|
142
|
+
def unknown_expand=(value)
|
|
143
|
+
raise ArgumentError, "unknown_expand must be :raise or :ignore" unless %i[raise ignore].include?(value)
|
|
144
|
+
|
|
145
|
+
@unknown_expand = value
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @param value [Symbol] :raise or :ignore
|
|
149
|
+
# @raise [ArgumentError] for any other value
|
|
150
|
+
def unknown_fields=(value)
|
|
151
|
+
raise ArgumentError, "unknown_fields must be :raise or :ignore" unless %i[raise ignore].include?(value)
|
|
152
|
+
|
|
153
|
+
@unknown_fields = value
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @param value [Symbol] :declaration or :alphabetical
|
|
157
|
+
# @raise [ArgumentError] for any other value
|
|
158
|
+
def field_order=(value)
|
|
159
|
+
raise ArgumentError, "field_order must be one of #{FieldOrder::VALUES.join(", ")}" unless FieldOrder::VALUES.include?(value)
|
|
160
|
+
|
|
161
|
+
@field_order = value
|
|
162
|
+
changed!
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @param value [Hash{String, Symbol => #call}] formatters keyed by JSON Schema format
|
|
166
|
+
# @raise [ArgumentError] when a formatter does not respond to call
|
|
167
|
+
def formatters=(value)
|
|
168
|
+
raise ArgumentError, "formatters must be a Hash of callables" unless value.is_a?(Hash) && value.each_value.all? { |formatter| formatter.respond_to?(:call) }
|
|
169
|
+
|
|
170
|
+
@formatters = value.transform_keys(&:to_s).freeze
|
|
171
|
+
changed!
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Types resolve when a field is defined, so set them before response classes load
|
|
175
|
+
#
|
|
176
|
+
# @param value [Hash{Symbol, String => Hash}] verquest-shaped types, e.g. {site_id: {type: "string", schema_options: {pattern: "^site_"}}}
|
|
177
|
+
# @raise [ArgumentError] when value is not a Hash or a type is invalid
|
|
178
|
+
# @raise [Versponse::DefinitionError] for a Regexp pattern ECMA-262 can't express
|
|
179
|
+
def custom_field_types=(value)
|
|
180
|
+
raise ArgumentError, "custom_field_types must be a Hash" unless value.is_a?(Hash)
|
|
181
|
+
|
|
182
|
+
@custom_field_types = value.to_h { |name, definition| [name.to_s, FieldType.normalize(name, definition)] }.freeze
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @param value [Symbol, #call] :raise, :nullify, :omit, or a handler called with (error, object, key, context)
|
|
186
|
+
# @raise [ArgumentError] for anything else, or a lambda or Method that can't take those four arguments
|
|
187
|
+
def on_error=(value)
|
|
188
|
+
raise ArgumentError, "on_error must be :raise, :nullify, :omit or a callable taking (error, object, key, context)" unless ErrorPolicy.valid?(value)
|
|
189
|
+
|
|
190
|
+
@on_error = value
|
|
191
|
+
changed!
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @param value [Array<Class>] StandardError subclasses a policy recovers
|
|
195
|
+
# @raise [ArgumentError] for anything but an Array of StandardError subclasses
|
|
196
|
+
def recoverable_errors=(value)
|
|
197
|
+
valid = value.is_a?(::Array) && value.all? { |klass| klass.is_a?(Class) && klass <= StandardError }
|
|
198
|
+
raise ArgumentError, "recoverable_errors must be an Array of StandardError subclasses" unless valid
|
|
199
|
+
|
|
200
|
+
@recoverable_errors = value.dup.freeze
|
|
201
|
+
changed!
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @param value [#call, nil] called with (error, response:, key:, object:) for each recovered error, nil for none
|
|
205
|
+
# @raise [ArgumentError] when value does not respond to call
|
|
206
|
+
def on_recovered_error=(value)
|
|
207
|
+
@on_recovered_error = value.nil? ? nil : callable!(value, :on_recovered_error)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @return [Boolean] true when nullable is written as `nullable: true` (OpenAPI 3.0)
|
|
211
|
+
def nullable_keyword?
|
|
212
|
+
json_schema_version == :openapi30
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [Boolean] false when constants are written as a one-value enum (draft 4 and OpenAPI 3.0)
|
|
216
|
+
def const_keyword?
|
|
217
|
+
!VERSIONS_WITHOUT_CONST.include?(json_schema_version)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @param name [String] anchor name
|
|
221
|
+
# @return [Hash{String => String}] the keyword naming a schema so "#<name>" references it
|
|
222
|
+
def anchor(name)
|
|
223
|
+
keyword = ANCHOR_KEYWORDS[json_schema_version]
|
|
224
|
+
keyword ? {keyword => "##{name}"} : {"$anchor" => name}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# @return [String] meta-schema URI of the configured JSON Schema version
|
|
228
|
+
def json_schema_uri
|
|
229
|
+
require "json_schemer"
|
|
230
|
+
|
|
231
|
+
JSONSchemer.const_get(SCHEMAS.fetch(json_schema_version))::BASE_URI.to_s
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
# Only the global configuration feeds the caches
|
|
237
|
+
def changed!
|
|
238
|
+
Versponse.reset_caches! if Versponse.configuration.equal?(self)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def boolean!(value, name)
|
|
242
|
+
raise ArgumentError, "#{name} must be true or false" unless [true, false].include?(value)
|
|
243
|
+
|
|
244
|
+
value
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Lambdas and Methods check their arity, so one that can't take the response class fails here, not on a render
|
|
248
|
+
def class_callable!(value, name, alternatives)
|
|
249
|
+
raise ArgumentError, "#{name} must be #{alternatives} or respond to call" unless value.respond_to?(:call)
|
|
250
|
+
raise ArgumentError, "#{name} must accept the response class" unless Callable.accepts?(value, 1)
|
|
251
|
+
|
|
252
|
+
value
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def callable!(value, name)
|
|
256
|
+
raise ArgumentError, "#{name} must respond to call" unless value.respond_to?(:call)
|
|
257
|
+
|
|
258
|
+
value
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Draft 4 and OpenAPI 3.0 have no const keyword, so every generated const (property options and nested
|
|
5
|
+
# schemas included) becomes a one-value enum. Data keywords keep their values, and name maps keep their names.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module ConstAsEnum
|
|
9
|
+
# Keywords whose values are data, not schemas
|
|
10
|
+
DATA_KEYWORDS = %w[example examples default enum const].freeze
|
|
11
|
+
|
|
12
|
+
# Keywords whose keys are names, with a schema for each
|
|
13
|
+
NAME_MAPS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
|
|
14
|
+
|
|
15
|
+
# @param schema [Object] generated schema
|
|
16
|
+
# @return [Object] a copy with every const as a one-value enum (an existing enum keeps only that value)
|
|
17
|
+
def self.call(schema)
|
|
18
|
+
case schema
|
|
19
|
+
when Hash then convert(schema)
|
|
20
|
+
when Array then schema.map { |item| call(item) }
|
|
21
|
+
else schema
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.convert(schema)
|
|
26
|
+
converted = schema.to_h do |key, item|
|
|
27
|
+
next [key, item] if DATA_KEYWORDS.include?(key)
|
|
28
|
+
next [key, item.transform_values { |child| call(child) }] if NAME_MAPS.include?(key) && item.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
[key, call(item)]
|
|
31
|
+
end
|
|
32
|
+
return converted unless converted.key?("const")
|
|
33
|
+
|
|
34
|
+
value = converted.delete("const")
|
|
35
|
+
converted.merge("enum" => converted.key?("enum") ? converted["enum"].select { |allowed| allowed == value } : [value])
|
|
36
|
+
end
|
|
37
|
+
private_class_method :convert
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module ContextOverride
|
|
5
|
+
# Calls a callable with the parent object and context
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Call
|
|
9
|
+
# @param callable [#call] normalized to (object, context)
|
|
10
|
+
def initialize(callable)
|
|
11
|
+
@callable = callable
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param object [Object] the object owning the property
|
|
16
|
+
# @param context [Hash] parent context
|
|
17
|
+
# @return [Hash] frozen child context
|
|
18
|
+
# @raise [Versponse::ExtractionError] when the callable returns anything but a Hash (the plan adds the keys)
|
|
19
|
+
def call(object, context)
|
|
20
|
+
child = @callable.call(object, context)
|
|
21
|
+
raise ExtractionError.new(label: "context", keys: [], detail: "context: must return a Hash, got #{child.class}") unless child.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
child.frozen? ? child : child.dup.freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module ContextOverride
|
|
5
|
+
# Merges a frozen Hash over the parent context
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Merge
|
|
9
|
+
# @param hash [Hash] deep-frozen values to merge
|
|
10
|
+
def initialize(hash)
|
|
11
|
+
@hash = hash
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param _object [Object] unused
|
|
16
|
+
# @param context [Hash] parent context
|
|
17
|
+
# @return [Hash] frozen child context; the built Hash itself over an empty context
|
|
18
|
+
def call(_object, context)
|
|
19
|
+
context.empty? ? @hash : context.merge(@hash).freeze
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# The context a nested property's subtree renders with: a Hash merged over the parent context, or a callable
|
|
5
|
+
# returning the child context
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module ContextOverride
|
|
9
|
+
# @param option [Hash, #call, nil] the property's context: option
|
|
10
|
+
# @param key [String] property key, for error messages
|
|
11
|
+
# @return [Merge, Call, nil] the override, nil without the option
|
|
12
|
+
# @raise [Versponse::DefinitionError] for any other value, or a callable taking neither (object) nor (object, context)
|
|
13
|
+
def self.build(option, key:)
|
|
14
|
+
return if option.nil?
|
|
15
|
+
return Merge.new(DeepFreeze.call(option)) if option.is_a?(Hash)
|
|
16
|
+
raise DefinitionError, "#{key}: context: must be a Hash or respond to call" unless option.respond_to?(:call)
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
Call.new(Callable.normalize(option))
|
|
20
|
+
rescue DefinitionError => e
|
|
21
|
+
raise DefinitionError, "#{key}: context: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# A one_of variant's plan rendered with the variant's context: (called with the matched value)
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class ContextPlan
|
|
8
|
+
# @param plan [#call] the variant's plan (full or collapsed)
|
|
9
|
+
# @param override [#call] the variant's context override
|
|
10
|
+
def initialize(plan, override)
|
|
11
|
+
@plan = plan
|
|
12
|
+
@override = override
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param object [Object] the matched value
|
|
17
|
+
# @param context [Hash] context of the one_of
|
|
18
|
+
# @param expansion [Versponse::Expansion] expand tree node
|
|
19
|
+
# @return [Hash] rendered object
|
|
20
|
+
def call(object, context, expansion = Expansion::NONE)
|
|
21
|
+
@plan.call(object, @override.call(object, context), expansion)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Unfrozen deep copy of a value handed to a caller who may change it
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module DeepCopy
|
|
8
|
+
# @param value [Object] value, usually frozen
|
|
9
|
+
# @return [Object] a copy whose Hashes, Arrays and Strings are new, unfrozen objects; other values as they are
|
|
10
|
+
def self.call(value)
|
|
11
|
+
case value
|
|
12
|
+
when ::Hash then value.to_h { |key, item| [key, call(item)] }
|
|
13
|
+
when ::Array then value.map { |item| call(item) }
|
|
14
|
+
when ::String then value.dup
|
|
15
|
+
else value
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Scalars render as they are; copying them on every render would only cost
|
|
20
|
+
#
|
|
21
|
+
# @param value [Object] value
|
|
22
|
+
# @return [Boolean] true for a Hash or Array, which renders as a copy
|
|
23
|
+
def self.container?(value)
|
|
24
|
+
value.is_a?(::Hash) || value.is_a?(::Array)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Deeply frozen copy of a schema. Copies instead of freezing in place, so schemas returned by
|
|
5
|
+
# other gems (verquest's internal hashes) are never frozen behind their back.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module DeepFreeze
|
|
9
|
+
# @param value [Object] Hash, Array, String or another value
|
|
10
|
+
# @return [Object] a frozen copy
|
|
11
|
+
def self.call(value)
|
|
12
|
+
case value
|
|
13
|
+
when Hash then value.to_h { |key, item| [call(key), call(item)] }.freeze
|
|
14
|
+
when Array then value.map { |item| call(item) }.freeze
|
|
15
|
+
when String then value.frozen? ? value : value.dup.freeze
|
|
16
|
+
else value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Collapsed one_of variant: the discriminator const followed by the class's identifier
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class DiscriminatedPlan
|
|
8
|
+
# @param plan [#call] the variant class's collapsed plan
|
|
9
|
+
# @param key [String] discriminator property
|
|
10
|
+
# @param value [Object] the variant's discriminator value
|
|
11
|
+
def initialize(plan, key:, value:)
|
|
12
|
+
@plan = plan
|
|
13
|
+
@key = key
|
|
14
|
+
@value = value
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param object [Object] source
|
|
19
|
+
# @param context [Hash] serialize context
|
|
20
|
+
# @param expansion [Versponse::Expansion] expand tree node
|
|
21
|
+
# @return [Hash] discriminator and identifier
|
|
22
|
+
def call(object, context, expansion = Expansion::NONE)
|
|
23
|
+
{@key => @value}.merge!(@plan.call(object, context, expansion))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module ErrorPolicy
|
|
5
|
+
# Applies one property's policy when its step fails, then reports the error
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Recovery
|
|
9
|
+
# @param policy [Symbol, #call] :nullify, :omit or a handler
|
|
10
|
+
# @param key [String] the property's key
|
|
11
|
+
# @param response [Class, nil] the class whose plan holds the property
|
|
12
|
+
# @param explicit [Boolean] declared on the property itself, so it also recovers nested failures
|
|
13
|
+
def initialize(policy, key:, response:, explicit:)
|
|
14
|
+
@policy = policy
|
|
15
|
+
@key = key
|
|
16
|
+
@response = response
|
|
17
|
+
@explicit = explicit
|
|
18
|
+
@errors = Versponse.configuration.recoverable_errors
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A failure from a child plan is recovered only by an explicit policy (an ExtractionError then too). Other
|
|
23
|
+
# Versponse errors, and errors raised by the on_recovered_error hook, are never recovered.
|
|
24
|
+
#
|
|
25
|
+
# @param error [StandardError] what the step raised
|
|
26
|
+
# @return [Boolean] true when this policy handles it
|
|
27
|
+
def recovers?(error)
|
|
28
|
+
return false if ErrorPolicy.unrecoverable?(error)
|
|
29
|
+
return @explicit && (error.is_a?(ExtractionError) || (!error.is_a?(Error) && recoverable?(error))) if ErrorPolicy.nested?(error)
|
|
30
|
+
|
|
31
|
+
!error.is_a?(Error) && recoverable?(error)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Writes nil, leaves the key out, or writes the handler's value. Each skips the step's inline check, so it
|
|
35
|
+
# forces full output validation (a nullable property's declared any_of may still reject nil, a left-out key may
|
|
36
|
+
# be required).
|
|
37
|
+
#
|
|
38
|
+
# @param error [StandardError] what the step raised
|
|
39
|
+
# @param object [Object] the object being rendered
|
|
40
|
+
# @param context [Hash] serialize context
|
|
41
|
+
# @param output [Hash] Hash being built
|
|
42
|
+
# @return [void]
|
|
43
|
+
def recover(error, object, context, output)
|
|
44
|
+
if @policy == :omit
|
|
45
|
+
output.delete(@key)
|
|
46
|
+
else
|
|
47
|
+
output[@key] = (@policy == :nullify) ? nil : @policy.call(error, object, @key, context)
|
|
48
|
+
end
|
|
49
|
+
InlineValidation.failed!
|
|
50
|
+
report(error, object)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# A hook error is marked, so a parent's policy doesn't recover it either
|
|
56
|
+
def report(error, object)
|
|
57
|
+
Versponse.configuration.on_recovered_error&.call(error, response: @response, key: @key, object:)
|
|
58
|
+
rescue => e
|
|
59
|
+
ErrorPolicy.mark_unrecoverable!(e)
|
|
60
|
+
raise
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def recoverable?(error)
|
|
64
|
+
@errors.any? { |klass| error.is_a?(klass) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# What happens when a property fails to render: :raise, :nullify, :omit, or a handler called with
|
|
5
|
+
# (error, object, key, context) whose return value is written
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module ErrorPolicy
|
|
9
|
+
# Policies given as Symbols
|
|
10
|
+
POLICIES = %i[raise nullify omit].freeze
|
|
11
|
+
|
|
12
|
+
# Arguments a handler is called with
|
|
13
|
+
HANDLER_ARGUMENTS = 4
|
|
14
|
+
|
|
15
|
+
# Instance variable marking an error raised inside a child plan
|
|
16
|
+
NESTED = :@versponse_nested_failure
|
|
17
|
+
|
|
18
|
+
# Instance variable marking an error no policy recovers (one raised by the on_recovered_error hook)
|
|
19
|
+
UNRECOVERABLE = :@versponse_unrecoverable
|
|
20
|
+
|
|
21
|
+
# @param policy [Object] a policy as given
|
|
22
|
+
# @return [Boolean] true for one of {POLICIES} or a callable taking (error, object, key, context)
|
|
23
|
+
def self.valid?(policy)
|
|
24
|
+
POLICIES.include?(policy) || Callable.accepts?(policy, HANDLER_ARGUMENTS)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A frozen error can't be marked, so a class-level or config-level policy may recover it when it comes from a
|
|
28
|
+
# child plan
|
|
29
|
+
#
|
|
30
|
+
# @param error [StandardError] error leaving a child plan
|
|
31
|
+
# @return [void]
|
|
32
|
+
def self.mark_nested!(error)
|
|
33
|
+
error.instance_variable_set(NESTED, true) unless error.frozen?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param error [StandardError] error
|
|
37
|
+
# @return [Boolean] true when it was raised inside a child plan
|
|
38
|
+
def self.nested?(error)
|
|
39
|
+
error.instance_variable_defined?(NESTED)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param error [StandardError] error raised by the on_recovered_error hook
|
|
43
|
+
# @return [void]
|
|
44
|
+
def self.mark_unrecoverable!(error)
|
|
45
|
+
error.instance_variable_set(UNRECOVERABLE, true) unless error.frozen?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param error [StandardError] error
|
|
49
|
+
# @return [Boolean] true when no policy may recover it
|
|
50
|
+
def self.unrecoverable?(error)
|
|
51
|
+
error.instance_variable_defined?(UNRECOVERABLE)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param properties [Array<Versponse::Properties::Base>] a plan's properties, in step order
|
|
55
|
+
# @param policies [Versponse::ClassPolicies] the compiling class's settings
|
|
56
|
+
# @param version [String] canonical version name, to find what a reference property: reuses
|
|
57
|
+
# @return [Array<Recovery, nil>, nil] a recovery per step, nil when no step has one
|
|
58
|
+
def self.recoveries(properties, policies, version)
|
|
59
|
+
list = properties.map { |property| resolve(property, property.path_target(version), policies) }
|
|
60
|
+
list.any? ? list.freeze : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The property's own policy, or the reused property's when it fits the copy (which has the reusing property's
|
|
64
|
+
# condition), else the first of the class's and config.on_error that fits what it renders (a :nullify only fits
|
|
65
|
+
# nullable properties, an :omit only conditional ones); :raise stops the search
|
|
66
|
+
#
|
|
67
|
+
# @param property [Versponse::Properties::Base] property as declared in the plan
|
|
68
|
+
# @param target [Versponse::Properties::Base] what it renders: itself, or the copy a reference property: reuses
|
|
69
|
+
# @param policies [Versponse::ClassPolicies] the compiling class's settings
|
|
70
|
+
# @return [Recovery, nil] nil when the property raises
|
|
71
|
+
def self.resolve(property, target, policies)
|
|
72
|
+
reused = target.on_error unless target.equal?(property)
|
|
73
|
+
own = property.on_error || (reused if reused && fits?(reused, target))
|
|
74
|
+
if own
|
|
75
|
+
return (own == :raise) ? nil : Recovery.new(own, key: property.key, response: policies.response, explicit: true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
[policies.on_error, Versponse.configuration.on_error].each do |level|
|
|
79
|
+
next if level.nil?
|
|
80
|
+
return nil if level == :raise
|
|
81
|
+
return Recovery.new(level, key: property.key, response: policies.response, explicit: false) if fits?(level, target)
|
|
82
|
+
end
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param policy [Symbol, #call] policy
|
|
87
|
+
# @param property [Versponse::Properties::Base] property
|
|
88
|
+
# @return [Boolean] true when the policy keeps the property's output within its schema
|
|
89
|
+
def self.fits?(policy, property)
|
|
90
|
+
case policy
|
|
91
|
+
when :nullify then property.nullable?
|
|
92
|
+
when :omit then property.conditional?
|
|
93
|
+
else true
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|