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,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