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