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,553 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Per-class state and compilation of a response class: its versions, resolution and caches.
5
+ # Kept off the class itself (reached through {Base::ClassMethods#versponse}), so the class
6
+ # exposes only the documented API and a user's own class methods can't clash with it.
7
+ #
8
+ # @api private
9
+ class Internals
10
+ # Most expand: combinations cached per class; they come from requests, so further ones are rebuilt each time
11
+ EXPANSION_LIMIT = 256
12
+
13
+ # Most fields: combinations cached per class (checked paths, pruned validation schemas)
14
+ SELECTION_LIMIT = 64
15
+
16
+ # @return [Versponse::Versions] declared versions
17
+ attr_reader :versions
18
+ # @return [Versponse::SupportedVersions, nil] range declared with supported_versions, nil to remove an inherited one
19
+ attr_writer :supported_version_range
20
+ # @return [#call, nil] on_nil declared on this class, nil to remove an inherited one
21
+ attr_writer :on_nil
22
+ # @return [Symbol, #call, nil] on_error declared on this class, nil to remove an inherited one
23
+ attr_writer :on_error
24
+
25
+ # @param klass [Class] response class
26
+ def initialize(klass)
27
+ @klass = klass
28
+ @versions = Versions.new(owner: klass.name || "AnonymousResponse")
29
+ @plans = Cache.new
30
+ @schemas = Cache.new
31
+ @schemers = Cache.new
32
+ @checkers = Cache.new
33
+ @validating_plans = Cache.new
34
+ @expandable_paths = Cache.new
35
+ @expandable_targets = Cache.new
36
+ @selectable_paths = Cache.new
37
+ @expansions = Cache.new(limit: EXPANSION_LIMIT)
38
+ @raw_expansions = Cache.new
39
+ @field_paths = Cache.new(limit: SELECTION_LIMIT)
40
+ @selected_validators = Cache.new(limit: SELECTION_LIMIT)
41
+ @time_precisions = {}
42
+ @generation = Versponse.cache_generation
43
+ end
44
+
45
+ # @return [Versponse::Builder] the class's DSL state and helpers
46
+ def builder
47
+ @builder ||= Builder.new(@klass)
48
+ end
49
+
50
+ # @return [String, nil] class-level description of this class or its closest ancestor that sets one
51
+ def description
52
+ versions.description || ancestor&.description
53
+ end
54
+
55
+ # Looked up when schemas are built, so declaration order of parent and subclass doesn't matter
56
+ #
57
+ # @return [Hash{String => Object}] class-level schema options of the ancestors merged with this class's own
58
+ def schema_options
59
+ (ancestor&.schema_options || {}).merge(versions.schema_options)
60
+ end
61
+
62
+ # @return [Versponse::SupportedVersions, nil] range declared with supported_versions on this class
63
+ # or its closest ancestor, nil when every declared version is served
64
+ def supported_version_range
65
+ return @supported_version_range if defined?(@supported_version_range)
66
+
67
+ ancestor&.supported_version_range
68
+ end
69
+
70
+ # @return [#call, nil] on_nil of this class or its closest ancestor that sets one
71
+ def on_nil
72
+ return @on_nil if defined?(@on_nil)
73
+
74
+ ancestor&.on_nil
75
+ end
76
+
77
+ # @return [Symbol, #call, nil] on_error of this class or its closest ancestor that sets one
78
+ def on_error
79
+ return @on_error if defined?(@on_error)
80
+
81
+ ancestor&.on_error
82
+ end
83
+
84
+ # @return [Versponse::ClassPolicies] class settings for compiling the class's steps
85
+ def policies
86
+ ClassPolicies.new(response: @klass, on_nil:, on_error:)
87
+ end
88
+
89
+ # @param digits [Integer] fraction digits
90
+ # @param from [String, nil] first version it applies to
91
+ # @return [void]
92
+ # @raise [Versponse::DefinitionError] when the class already sets the precision from that version
93
+ def add_time_precision(digits, from:)
94
+ raise DefinitionError, "#{@klass.name || "AnonymousResponse"} sets time_precision #{from ? "from: #{from}" : "without from:"} twice" if @time_precisions.key?(from)
95
+
96
+ @time_precisions = @time_precisions.merge(from => digits).freeze
97
+ end
98
+
99
+ # A class that sets any time_precision uses only its own settings; one that sets none inherits the
100
+ # closest ancestor's, so two classes' timelines never mix
101
+ #
102
+ # @return [Hash{String, nil => Integer}] time_precision settings, keyed by from:
103
+ def time_precisions
104
+ return @time_precisions unless @time_precisions.empty?
105
+
106
+ ancestor&.time_precisions || @time_precisions
107
+ end
108
+
109
+ # @param version [String] canonical version name
110
+ # @return [Hash{String => #call}] config.formatters, with the date-time formatter of the class's time_precision for the version
111
+ def formatters(version)
112
+ precisions = time_precisions
113
+ from = Versponse.configuration.version_resolver.call(version, precisions.keys.compact.sort)
114
+ precision = precisions.fetch(from) { precisions[nil] }
115
+ return Versponse.configuration.formatters if precision.nil?
116
+
117
+ Versponse.configuration.formatters.merge("date-time" => Formatters.date_time(precision))
118
+ end
119
+
120
+ # Names that resolve within supported_versions: the declared versions in range plus min,
121
+ # so a min: "preview" class whose only block is 2025-09 compiles that block as "preview"
122
+ #
123
+ # @return [Array<String>] canonical version names; every declared version without a range
124
+ # @raise [Versponse::DefinitionError] when supported_versions max sorts before every declared version
125
+ def supported_version_names
126
+ versions.supported_names(supported_version_range)
127
+ end
128
+
129
+ # @param version [String] requested version
130
+ # @return [Versponse::Version] closest declared version at or before it
131
+ # @raise [Versponse::VersionNotFoundError] when there is none, or supported_versions leaves it out
132
+ # @raise [Versponse::DefinitionError] when supported_versions max sorts before every declared version
133
+ def resolve(version)
134
+ fresh!
135
+ versions.resolve(Registry.canonical(version.to_s), supported: supported_version_range)
136
+ end
137
+
138
+ # Yields the canonical name of the requested version. Caches and errors work with canonical names, so a
139
+ # VersionNotFoundError is re-raised naming what was requested.
140
+ #
141
+ # @param version [String, Symbol, nil] requested version, config.current_version when nil
142
+ # @yieldparam canonical [String] canonical version name
143
+ # @return [Object] the block's result
144
+ # @raise [ArgumentError] without a version when config.current_version is not set
145
+ def requesting(version)
146
+ requested = version&.to_s || current_version_name
147
+ canonical = Registry.canonical(requested)
148
+ yield canonical
149
+ rescue VersionNotFoundError => e
150
+ raise if canonical.nil? || requested == canonical
151
+
152
+ raise e.exception(e.message.sub("Version #{canonical} ", "Version #{requested} "))
153
+ end
154
+
155
+ # @param version [String] canonical (declared) version name, as serialize and nested references pass it;
156
+ # raw client strings would grow the cache
157
+ # @param view [Symbol] view name
158
+ # @return [Versponse::Plan] compiled, cached plan
159
+ def plan(version:, view: :default)
160
+ fresh!
161
+ compiled = @plans[version]&.[](view)
162
+ return compiled if compiled
163
+
164
+ # Keyed by version, then view, so a cached lookup allocates no key Array. resolve raises for an unknown
165
+ # version before the outer entry is stored, so client strings can't grow the cache.
166
+ @plans.fetch(version) { resolve(version) && Cache.new }.fetch(view.to_sym) do
167
+ definition = resolve(version)
168
+ formatters = formatters(version)
169
+ policies = self.policies
170
+ properties = definition.properties_for(view)
171
+ steps = properties.map { |property| property.step(version:, formatters:, policies:) }
172
+ Plan.new(label: "#{@klass.name}[#{definition.name}/#{view}]", steps:, recoveries: ErrorPolicy.recoveries(properties, policies, version),
173
+ always: definition.identifiers.keys)
174
+ end
175
+ end
176
+
177
+ # @param version [String] requested version name
178
+ # @param view [Symbol] view name
179
+ # @param context [Versponse::SchemaContext] schema generation context
180
+ # @return [Hash] object schema of the version and view
181
+ def object_schema(version:, view:, context:)
182
+ definition = resolve(version)
183
+ # Collapsed shapes are strict, or any object with an identifier would pass as the full one
184
+ return ObjectSchema.build(definition.properties_for(:collapsed), context, additional_properties: false) if view.to_sym == :collapsed
185
+
186
+ # Class-level settings are looked up now (live); the version's own win
187
+ ObjectSchema.build(definition.properties_for(view), context, description: definition.description || description,
188
+ schema_options: schema_options.merge(definition.schema_options))
189
+ end
190
+
191
+ # @param version [String] canonical version name
192
+ # @param view [Symbol] view name
193
+ # @return [Hash] frozen OpenAPI schema
194
+ def openapi_schema(version:, view:)
195
+ fresh!
196
+ @schemas.fetch([:openapi, version, view.to_sym]) do
197
+ finished(object_schema(version:, view:, context: SchemaContext.new(version:, validation: false)))
198
+ end
199
+ end
200
+
201
+ # @param version [String] canonical version name
202
+ # @param view [Symbol] view name
203
+ # @return [Hash] frozen schema with every reference inlined
204
+ def validation_schema(version:, view:)
205
+ fresh!
206
+ @schemas.fetch([:validation, version, view.to_sym]) do
207
+ context = SchemaContext.new(version:, validation: true)
208
+ finished(context.inline([@klass, view.to_sym], @klass.component_name(view:)) { object_schema(version:, view:, context:) })
209
+ end
210
+ end
211
+
212
+ # @param version [String] canonical version name
213
+ # @param view [Symbol] view name
214
+ # @param key [String] property key
215
+ # @return [Hash] frozen validation schema of one property
216
+ # @raise [Versponse::PropertyNotFoundError] for an unknown property
217
+ def property_validation_schema(version:, view:, key:)
218
+ fresh!
219
+ @schemas.fetch([:validation, version, view.to_sym, key]) do
220
+ target = resolve(version).properties_for(view).find { |property| property.key == key } ||
221
+ raise(PropertyNotFoundError, "Property #{key} not found in #{@klass.name} #{version}/#{view}")
222
+ finished(target.schema(SchemaContext.new(version:, validation: true)))
223
+ end
224
+ end
225
+
226
+ # @param version [String] canonical (declared) version name
227
+ # @param view [Symbol] view name
228
+ # @return [JSONSchemer::Schema] compiled, cached validation schema
229
+ def schemer(version:, view: :default)
230
+ fresh!
231
+ @schemers.fetch([version, view.to_sym]) do
232
+ require "json_schemer"
233
+ # Patterns are ECMA-262, as in documents (JSONSchemer.openapi reads them the same way)
234
+ JSONSchemer.schema(validation_schema(version:, view:), meta_schema: Versponse.configuration.json_schema_uri, regexp_resolver: "ecma")
235
+ end
236
+ end
237
+
238
+ # @param version [String] canonical (declared) version name
239
+ # @param view [Symbol] view name
240
+ # @return [#valid?, nil] fast checker compiled from the validation schema, nil when it uses keywords the compiler does not support
241
+ def checker(version:, view: :default)
242
+ fresh!
243
+ compiled = @checkers[version]&.[](view)
244
+ return compiled if compiled
245
+
246
+ # Keyed by version, then view, like plan, so a hit allocates no key Array; a nil checker (unsupported
247
+ # schema) misses the line above and is found by the nested fetch, which allocates nothing either
248
+ @checkers.fetch(version) { resolve(version) && Cache.new }.fetch(view.to_sym) do
249
+ Validator::Compiler.call(validation_schema(version:, view:), schemer(version:, view:))
250
+ end
251
+ end
252
+
253
+ # @param version [String] canonical version name
254
+ # @param view [Symbol] view name
255
+ # @param selection [Versponse::Selection] a root selection (not ALL)
256
+ # @return [Array(#valid?, JSONSchemer::Schema)] compiled checker (nil when unsupported) and json_schemer schema of
257
+ # the validation schema pruned for the selection, cached by its signature
258
+ def selected_validators(version:, view:, selection:)
259
+ fresh!
260
+ @selected_validators.fetch([version, view.to_sym, selection.signature]) do
261
+ require "json_schemer"
262
+ schema = DeepFreeze.call(SelectionSchema.call(validation_schema(version:, view:), selection))
263
+ schemer = JSONSchemer.schema(schema, meta_schema: Versponse.configuration.json_schema_uri, regexp_resolver: "ecma")
264
+ [Validator::Compiler.call(schema, schemer), schemer].freeze
265
+ end
266
+ end
267
+
268
+ # @param version [String] canonical (declared) version name
269
+ # @param view [Symbol] view name
270
+ # @return [Versponse::Plan, nil] plan that validates while rendering (see {InlineValidation::Builder}), nil when
271
+ # the validation schema doesn't prove one
272
+ def validating_plan(version:, view: :default)
273
+ fresh!
274
+ view = view.to_sym
275
+ cache = @validating_plans
276
+ cached = cache[version]&.[](view)
277
+ return (cached unless UNPROVEN.equal?(cached)) if cached
278
+
279
+ # Built outside the cache lock, so it never holds this class's lock while reading other classes' plans (a
280
+ # duplicate build in a race is harmless); stored into the cache read above, so a reset meanwhile discards it
281
+ built = build_validating_plan(version:, view:) || UNPROVEN
282
+ stored = cache.fetch(version) { resolve(version) && Cache.new }.fetch(view) { built }
283
+ stored unless UNPROVEN.equal?(stored)
284
+ end
285
+
286
+ # @param version [String] canonical version name
287
+ # @param view [Symbol] view name
288
+ # @return [Array<String>] sorted, frozen expandable paths
289
+ def expandable_paths(version:, view:)
290
+ fresh!
291
+ @expandable_paths.fetch([version, view.to_sym]) do
292
+ expandable_entries(version, view).map { |path, _classes| path.join(".") }.uniq.sort.freeze
293
+ end
294
+ end
295
+
296
+ # @param version [String] canonical version name
297
+ # @param view [Symbol] view name
298
+ # @return [Hash{String => Array<Class>}] frozen, keyed by the sorted expandable paths; each value lists the
299
+ # classes the path renders, in declaration order
300
+ def expandable_targets(version:, view:)
301
+ fresh!
302
+ @expandable_targets.fetch([version, view.to_sym]) do
303
+ targets = Hash.new { |hash, path| hash[path] = [] }
304
+ expandable_entries(version, view).each { |path, classes| targets[path.join(".")] |= classes }
305
+ targets.sort.to_h { |path, classes| [path, classes.freeze] }.freeze
306
+ end
307
+ end
308
+
309
+ # @param version [String] canonical version name
310
+ # @param view [Symbol] view name
311
+ # @return [Array<String>] sorted, frozen paths fields: accepts (without wildcards)
312
+ def selectable_paths(version:, view:)
313
+ fresh!
314
+ @selectable_paths.fetch([version, view.to_sym]) do
315
+ paths = []
316
+ collect_field_paths(resolve(version).properties_for(view), [], version, [], paths)
317
+ paths.uniq.sort.freeze
318
+ end
319
+ end
320
+
321
+ # Trees are cached by their sorted paths, up to {EXPANSION_LIMIT} combinations. A list with unknown paths
322
+ # (config.unknown_expand = :ignore) is cached under its valid paths only, so junk can't take the slots.
323
+ #
324
+ # @param expand [Array<String, Symbol>, String, Symbol, nil] requested paths: dotted, "*", or ending in ".*"
325
+ # @param version [String] canonical version name
326
+ # @param view [Symbol] view name
327
+ # @return [Versponse::Expansion] the expand tree
328
+ # @raise [Versponse::InvalidExpandError] for unknown paths (config.unknown_expand = :raise)
329
+ def expansion(expand, version:, view:)
330
+ return Expansion::NONE if expand.nil?
331
+
332
+ fresh!
333
+ raw = @raw_expansions[version]&.[](view)&.[](expand)
334
+ return raw if raw
335
+
336
+ paths = Array(expand).map(&:to_s).uniq
337
+ return Expansion::NONE if paths.empty?
338
+
339
+ tree = @expansions[[version, view.to_sym, paths.sort]]
340
+ return remember_expansion(expand, tree, version:, view:) if tree
341
+
342
+ validated_expansion(paths, version:, view:) { |known| remember_expansion(expand, known, version:, view:) }
343
+ end
344
+
345
+ # @param fields [Array<String, Symbol>, nil] requested paths: dotted, "*", or ending in ".*"
346
+ # @param version [String] canonical version name
347
+ # @param view [Symbol] view name
348
+ # @param expansion [Versponse::Expansion] the render's expand tree
349
+ # @return [Versponse::Selection] the selection, ALL for no paths
350
+ # @raise [Versponse::InvalidFieldsError] for unknown paths and paths below unexpanded associations
351
+ # (config.unknown_fields = :raise)
352
+ def selection(fields, version:, view:, expansion:)
353
+ return Selection::ALL if fields.nil?
354
+
355
+ paths = Array(fields).map(&:to_s).uniq
356
+ return Selection::ALL if paths.empty? || paths.include?("*")
357
+
358
+ fresh!
359
+ # The checked selection is cached with its step-index memo; expansions are cached trees, so the same expand:
360
+ # gives the same key
361
+ selection, reasons = @field_paths.fetch([version, view.to_sym, paths.sort, expansion]) do
362
+ problems = paths.to_h { |path| [path, field_path_problem(path, version:, view:, expansion:)] }.compact
363
+ valid = paths - problems.keys
364
+ # Paths were given, so none left selects nothing (Selection.build reads no paths as everything)
365
+ [valid.empty? ? Selection.none : Selection.build(valid), problems.freeze].freeze
366
+ end
367
+ raise InvalidFieldsError.new(paths: reasons.keys, reasons:) if reasons.any? && Versponse.configuration.unknown_fields == :raise
368
+
369
+ selection
370
+ end
371
+
372
+ # Checks without compiling that every referenced class serves the version and view, in every view,
373
+ # so eager_load! catches nested mistakes at versions the class serves but doesn't declare
374
+ #
375
+ # @param version [String] canonical version name
376
+ # @return [void]
377
+ # @raise [Versponse::DefinitionError] see {References.check_target!}
378
+ # @raise [Versponse::VersionNotFoundError] when the class (or a referenced class's range) doesn't serve the version
379
+ def check_references!(version)
380
+ definition = resolve(version)
381
+ definition.view_names.each do |view|
382
+ definition.properties_for(view).flat_map(&:reference_targets).each do |klass, target_view, key|
383
+ References.check_target!(klass, version:, view: target_view, key:)
384
+ end
385
+ end
386
+ end
387
+
388
+ private
389
+
390
+ def expandable_entries(version, view)
391
+ References.nested_entries(@klass, view: view.to_sym, version:, prefix: [], stack: [])
392
+ end
393
+
394
+ # Version-specific rewrites of a generated schema, then a frozen copy
395
+ def finished(schema)
396
+ DeepFreeze.call(Versponse.configuration.const_keyword? ? schema : ConstAsEnum.call(schema))
397
+ end
398
+
399
+ def ancestor
400
+ superclass = @klass.superclass
401
+ superclass.versponse if superclass < Base
402
+ end
403
+
404
+ # Versponse.reset_caches! bumps a global generation instead of walking every class; each class clears
405
+ # its own caches on next use. One Integer comparison per call.
406
+ def fresh!
407
+ generation = Versponse.cache_generation
408
+ return if @generation == generation
409
+
410
+ # New objects, not clear: a compile in flight on this thread holds the old caches' locks,
411
+ # and finishes into the discarded cache
412
+ @plans = Cache.new
413
+ @schemas = Cache.new
414
+ @schemers = Cache.new
415
+ @checkers = Cache.new
416
+ @validating_plans = Cache.new
417
+ @expandable_paths = Cache.new
418
+ @expandable_targets = Cache.new
419
+ @selectable_paths = Cache.new
420
+ @expansions = Cache.new(limit: EXPANSION_LIMIT)
421
+ @raw_expansions = Cache.new
422
+ @field_paths = Cache.new(limit: SELECTION_LIMIT)
423
+ @selected_validators = Cache.new(limit: SELECTION_LIMIT)
424
+ versions.clear_resolved
425
+ @generation = generation
426
+ end
427
+
428
+ # Stored for a class/view without a validating plan, so it isn't rebuilt on every render
429
+ UNPROVEN = Object.new.freeze
430
+ private_constant :UNPROVEN
431
+
432
+ # Compiles the validation schema once for both: the root checker goes into the checker cache read before the
433
+ # build (a reset meanwhile discards it), and the compiler's subschema checkers prove the plan
434
+ def build_validating_plan(version:, view:)
435
+ checkers = @checkers
436
+ schema = validation_schema(version:, view:)
437
+ compiler = Validator::Compiler.new(schemer(version:, view:))
438
+ root = begin
439
+ compiler.root(schema)
440
+ rescue Validator::Compiler::Unsupported
441
+ nil
442
+ end
443
+ checkers.fetch(version) { resolve(version) && Cache.new }.fetch(view) { root }
444
+ InlineValidation::Builder.new(compiler).prove(plan(version:, view:), schema) if root
445
+ end
446
+
447
+ def current_version_name
448
+ current = Versponse.configuration.current_version
449
+ raise ArgumentError, "version: is required when config.current_version is not set" unless current
450
+
451
+ value = current.call
452
+ raise ArgumentError, "config.current_version returned nil" if value.nil?
453
+
454
+ value.to_s
455
+ end
456
+
457
+ # Yields the tree when every path is known, so the requested value can be remembered as a key
458
+ def validated_expansion(paths, version:, view:)
459
+ valid, unknown = paths.partition { |path| known_path?(path, version:, view:) }
460
+ raise InvalidExpandError.new(paths: unknown) if unknown.any? && Versponse.configuration.unknown_expand == :raise
461
+ return Expansion::NONE if valid.empty?
462
+
463
+ tree = @expansions.fetch([version, view.to_sym, valid.sort]) do
464
+ Expansion.build(valid.flat_map { |path| expanded_paths(path, version:, view:) })
465
+ end
466
+ unknown.empty? ? yield(tree) : tree
467
+ end
468
+
469
+ # Stores the tree under the requested value as given (an Array as a frozen copy), so a repeated
470
+ # expand: is found without normalizing or allocating. Only lists whose paths are all known get here.
471
+ def remember_expansion(expand, tree, version:, view:)
472
+ key = (expand.is_a?(::Array) && !expand.frozen?) ? expand.dup.freeze : expand
473
+ by_view = @raw_expansions.fetch(version) { Cache.new }
474
+ by_view.fetch(view) { Cache.new(limit: EXPANSION_LIMIT) }.fetch(key) { tree }
475
+ end
476
+
477
+ # "*" is always known; "<path>.*" when the path is expandable or has expandable paths below it
478
+ def known_path?(path, version:, view:)
479
+ return true if path == "*"
480
+
481
+ base = path.delete_suffix(".*")
482
+ return false if base.split(".", -1).include?("")
483
+ return true if expandable_path?(base, version:, view:)
484
+
485
+ base != path && expandable_paths(version:, view:).any? { |known| known.start_with?("#{base}.") }
486
+ end
487
+
488
+ # "*" expands every path expandable_paths lists, so each recursive association expands once and cyclic
489
+ # object graphs can't recurse forever; "<path>.*" is the path (when expandable) and every listed path below it
490
+ def expanded_paths(path, version:, view:)
491
+ return expandable_paths(version:, view:) if path == "*"
492
+ return [path] unless path.end_with?(".*")
493
+
494
+ base = path.delete_suffix(".*")
495
+ below = expandable_paths(version:, view:).select { |known| known.start_with?("#{base}.") }
496
+ expandable_path?(base, version:, view:) ? [base, *below] : below
497
+ end
498
+
499
+ # Walks the requested path one segment at a time; valid when the last segment is expandable.
500
+ # Every property with a matching key is tried, because one_of variants can share keys.
501
+ def expandable_path?(path, version:, view:)
502
+ path_known?(resolve(version).properties_for(view), path.split("."), version)
503
+ end
504
+
505
+ def path_known?(properties, segments, version)
506
+ head, *rest = segments
507
+ properties.select { |property| property.key == head }.any? do |property|
508
+ target = property.path_target(version)
509
+ rest.empty? ? target.expandable_path? : path_known?(target.path_children(version), rest, version)
510
+ end
511
+ end
512
+
513
+ # nil for a valid path, else why it isn't
514
+ def field_path_problem(path, version:, view:, expansion:)
515
+ segments = path.delete_suffix(".*").split(".", -1)
516
+ return "unknown" if segments.empty? || segments.include?("") || segments.include?("*")
517
+
518
+ field_walk(resolve(version).properties_for(view), segments, version, expansion, [])
519
+ end
520
+
521
+ # Every property with a matching key is tried, because one_of variants can share keys
522
+ def field_walk(properties, segments, version, expansion, walked)
523
+ head, *rest = segments
524
+ candidates = properties.select { |property| property.key == head }
525
+ return "unknown" if candidates.empty?
526
+ return if rest.empty?
527
+
528
+ problems = candidates.map do |property|
529
+ target = property.path_target(version)
530
+ next "#{[*walked, head].join(".")} isn't expanded" if target.expandable_path? && !expansion.expand?(head)
531
+
532
+ field_walk(target.path_children(version), rest, version, expansion.child(head), [*walked, head])
533
+ end
534
+ problems.include?(nil) ? nil : problems.first
535
+ end
536
+
537
+ # Deepest walk for reused copies (reference property:), whose objects differ on every call
538
+ FIELD_PATH_DEPTH = 32
539
+ private_constant :FIELD_PATH_DEPTH
540
+
541
+ # A property already on the walked stack (a recursive association) is listed, but not walked again
542
+ def collect_field_paths(properties, prefix, version, stack, paths)
543
+ properties.each do |property|
544
+ path = [*prefix, property.key]
545
+ paths << path.join(".")
546
+ target = property.path_target(version)
547
+ next if stack.any? { |walked| walked.equal?(target) } || stack.size >= FIELD_PATH_DEPTH
548
+
549
+ collect_field_paths(target.path_children(version), path, version, [*stack, target], paths)
550
+ end
551
+ end
552
+ end
553
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Values with a JSON type, which a const renders as written. Numbers are accepted as enum accepts them (any real,
5
+ # finite Numeric); Hash keys may be Symbols, which JSON generators and output validation read as Strings.
6
+ #
7
+ # @api private
8
+ module JSONValue
9
+ # @param value [Object] value
10
+ # @return [Boolean] true for nil, true, false, Strings, real finite numbers, and Arrays and Hashes (with String
11
+ # or Symbol keys) of them
12
+ def self.json?(value)
13
+ case value
14
+ when nil, true, false, ::String then true
15
+ when ::Numeric then value.real? && value.finite?
16
+ when ::Array then value.all? { |item| json?(item) }
17
+ when ::Hash then value.all? { |key, item| (key.is_a?(::String) || key.is_a?(::Symbol)) && json?(item) }
18
+ else false
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Handle to another class's plan, compiled on first use so cyclic references are safe
5
+ #
6
+ # @api private
7
+ class LazyPlan
8
+ # @param klass [Class] Versponse response class
9
+ # @param version [String] requested version name
10
+ # @param view [Symbol] view name
11
+ def initialize(klass, version:, view:)
12
+ @klass = klass
13
+ @version = version
14
+ @view = view
15
+ end
16
+
17
+ # @param object [Object] source
18
+ # @param context [Hash] serialize context
19
+ # @param expansion [Versponse::Expansion] expand tree node
20
+ # @return [Hash] rendered object
21
+ def call(object, context, expansion = Expansion::NONE)
22
+ (@plan ||= resolve).call(object, context, expansion)
23
+ end
24
+
25
+ # @return [Versponse::Plan] the compiled plan this handle renders with
26
+ def resolve
27
+ @klass.versponse.plan(version: @version, view: @view)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Converts snake_case names to the camelCase and PascalCase used in schemas
5
+ #
6
+ # @api private
7
+ module Naming
8
+ # @param name [String, Symbol] snake_case name
9
+ # @return [String] camelCase name
10
+ def self.camelize(name)
11
+ first, *rest = name.to_s.split("_")
12
+ first + rest.map(&:capitalize).join
13
+ end
14
+
15
+ # Keywords whose values are data, not schema, so their nested keys are kept as given
16
+ VALUE_KEYWORDS = %w[example examples default const enum].freeze
17
+
18
+ # Keywords whose keys are user-chosen names or payload values, kept as given; only their Hash values are schemas
19
+ NAME_MAPS = %w[properties patternProperties $defs definitions dependentRequired dependentSchemas mapping].freeze
20
+
21
+ # @param hash [Hash] snake_case keys, nested schema Hashes and Arrays of them included
22
+ # @return [Hash{String => Object}] camelCase String keys at every schema level; keys of
23
+ # name maps (properties, patternProperties, $defs, definitions, dependentRequired, dependentSchemas, a
24
+ # discriminator's mapping) are only stringified, "x-" extensions are kept with their values as given, and a
25
+ # Regexp pattern becomes an ECMA-262 String (see {Pattern.source})
26
+ # @raise [Versponse::DefinitionError] for a Regexp pattern ECMA-262 can't express
27
+ def self.camelize_keys(hash)
28
+ hash.to_h do |key, value|
29
+ key = extension?(key) ? key.to_s : camelize(key)
30
+ [key, camelize_value(key, value)]
31
+ end
32
+ end
33
+
34
+ def self.extension?(key)
35
+ key.to_s.start_with?("x-")
36
+ end
37
+
38
+ def self.camelize_value(key, value)
39
+ return value if VALUE_KEYWORDS.include?(key) || extension?(key)
40
+ return Pattern.source(value) if key == "pattern"
41
+ return camelize_name_map(value) if NAME_MAPS.include?(key) && value.is_a?(Hash)
42
+
43
+ camelize_nested(value)
44
+ end
45
+
46
+ def self.camelize_nested(value)
47
+ case value
48
+ when Hash then camelize_keys(value)
49
+ when Array then value.map { |element| camelize_nested(element) }
50
+ else value
51
+ end
52
+ end
53
+
54
+ def self.camelize_name_map(map)
55
+ map.to_h { |name, value| [name.to_s, value.is_a?(Hash) ? camelize_keys(value) : value] }
56
+ end
57
+
58
+ private_class_method :extension?, :camelize_value, :camelize_nested, :camelize_name_map
59
+
60
+ # Capitals inside a part are kept, so :fullDetails and :full_details both give "FullDetails"
61
+ #
62
+ # @param name [String, Symbol] snake_case or camelCase name
63
+ # @return [String] PascalCase name
64
+ def self.pascalize(name)
65
+ name.to_s.split("_").map { |part| part.sub(/\A[a-z]/, &:upcase) }.join
66
+ end
67
+ end
68
+ end