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,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# const and enum, compared with == as json_schemer does
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class ValueCheck
|
|
9
|
+
# @return [Array<String>] keywords this check owns
|
|
10
|
+
def self.keywords = %w[const enum]
|
|
11
|
+
|
|
12
|
+
# @param schema [Hash] schema
|
|
13
|
+
# @param _compiler [Versponse::Validator::Compiler] unused
|
|
14
|
+
# @return [ValueCheck, nil] the check, nil without const and enum
|
|
15
|
+
def self.build(schema, _compiler)
|
|
16
|
+
return unless schema.key?("const") || schema.key?("enum")
|
|
17
|
+
|
|
18
|
+
new(schema.key?("const") ? [schema["const"]] : nil, schema["enum"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param const [Array, nil] the const wrapped in an Array, nil without const
|
|
22
|
+
# @param enum [Array, nil] allowed values, nil without enum
|
|
23
|
+
def initialize(const, enum)
|
|
24
|
+
@const = const
|
|
25
|
+
@enum = enum
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param data [Object] value to check
|
|
30
|
+
# @return [Boolean] true when data equals the const and is in the enum
|
|
31
|
+
def valid?(data)
|
|
32
|
+
(@const.nil? || @const.first == data) && (@enum.nil? || @enum.include?(data))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Fast checks compiled from validation schemas. They only answer "valid?"; json_schemer stays
|
|
5
|
+
# the authority for errors and for schemas the compiler doesn't support.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module Validator
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Picks the plan of the first variant whose matchers === the value. Used for a single one_of
|
|
5
|
+
# and for every item of a polymorphic collection.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class VariantDispatcher
|
|
9
|
+
# @param variants [Array<Array(Array<#===>, #call)>] matchers and plan pairs
|
|
10
|
+
# @param nullable [Boolean] render nil as nil instead of failing to match
|
|
11
|
+
def initialize(variants, nullable:)
|
|
12
|
+
@variants = variants.freeze
|
|
13
|
+
@nullable = nullable
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param value [Object] value to render
|
|
18
|
+
# @param context [Hash] serialize context
|
|
19
|
+
# @param expansion [Versponse::Expansion] expand tree node for the value
|
|
20
|
+
# @return [Hash, nil] the value rendered by its variant
|
|
21
|
+
# @raise [KeyError] when no variant matches (the plan wraps it in an ExtractionError)
|
|
22
|
+
def call(value, context, expansion = Expansion::NONE)
|
|
23
|
+
return nil if value.nil? && @nullable
|
|
24
|
+
|
|
25
|
+
plan_for(value).call(value, context, expansion)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def plan_for(value)
|
|
31
|
+
@variants.each { |matchers, plan| return plan if matchers.any? { |matcher| matcher === value } }
|
|
32
|
+
raise KeyError, "no one_of variant matches #{value.class}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/versponse/version.rb
CHANGED
|
@@ -1,5 +1,183 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Versponse
|
|
4
|
-
|
|
4
|
+
# One version of a response class: its properties, views and identifier. Frozen once the version block closes.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class Version
|
|
8
|
+
# @return [String] version name
|
|
9
|
+
attr_reader :name
|
|
10
|
+
# @return [Hash{String => Versponse::Properties::Base}] properties of the default view
|
|
11
|
+
attr_reader :properties
|
|
12
|
+
# @return [Hash{Symbol => Versponse::View}] declared and inherited views
|
|
13
|
+
attr_reader :views
|
|
14
|
+
# @return [Hash{String => Versponse::Properties::Field}] identifier fields, the implicit :collapsed view
|
|
15
|
+
attr_reader :identifiers
|
|
16
|
+
# @return [#call, nil] identifier condition called with (object, context); when it returns false,
|
|
17
|
+
# an expandable association that isn't expanded renders in full
|
|
18
|
+
attr_reader :collapse_if
|
|
19
|
+
# @return [String, nil] schema description
|
|
20
|
+
attr_accessor :description
|
|
21
|
+
# @return [Hash{String => Object}] camelized schema keywords of the version
|
|
22
|
+
attr_accessor :schema_options
|
|
23
|
+
|
|
24
|
+
# @param name [String] version name
|
|
25
|
+
# @param owner [String] response class name, for messages
|
|
26
|
+
def initialize(name:, owner:)
|
|
27
|
+
@name = name
|
|
28
|
+
@owner = owner
|
|
29
|
+
@properties = {}
|
|
30
|
+
@views = {}
|
|
31
|
+
@identifiers = {}
|
|
32
|
+
@collapse_if = nil
|
|
33
|
+
@schema_options = {}
|
|
34
|
+
@declared_keys = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param property [Versponse::Properties::Base] property to add or replace
|
|
38
|
+
# @return [Versponse::Properties::Base] the property
|
|
39
|
+
# @raise [Versponse::DefinitionError] when an identifier declares the key
|
|
40
|
+
def add(property)
|
|
41
|
+
raise DefinitionError, "#{label}: #{property.key} is declared by identifier" if identifiers.key?(property.key)
|
|
42
|
+
|
|
43
|
+
@declared_keys << property.key
|
|
44
|
+
properties[property.key] = property
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The first identifier declared in a version replaces the inherited ones (and their collapse_if); later ones add to it
|
|
48
|
+
#
|
|
49
|
+
# @param property [Versponse::Properties::Field] identifier field
|
|
50
|
+
# @param property_in_full [Versponse::Properties::Field] the field in the full shape (conditional with collapse_if)
|
|
51
|
+
# @param collapse_if [#call, nil] normalized condition, at most one per version
|
|
52
|
+
# @return [Versponse::Properties::Field] the field
|
|
53
|
+
# @raise [Versponse::DefinitionError] when another identifier of the version already declares collapse_if
|
|
54
|
+
def add_identifier(property, property_in_full: property, collapse_if: nil)
|
|
55
|
+
if @inherited_identifiers
|
|
56
|
+
# The old identifiers' conditional copies (collapse_if) go back to plain fields
|
|
57
|
+
identifiers.each { |key, field| properties[key] = field if properties.key?(key) }
|
|
58
|
+
@identifiers = {}
|
|
59
|
+
@collapse_if = nil
|
|
60
|
+
@inherited_identifiers = false
|
|
61
|
+
end
|
|
62
|
+
if collapse_if
|
|
63
|
+
raise DefinitionError, "#{label}: collapse_if is already declared by another identifier" if @collapse_if
|
|
64
|
+
|
|
65
|
+
@collapse_if = collapse_if
|
|
66
|
+
end
|
|
67
|
+
@declared_keys << property.key
|
|
68
|
+
properties[property.key] = property_in_full
|
|
69
|
+
identifiers[property.key] = property
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] true when the version declares or inherits an identifier
|
|
73
|
+
def identifiers?
|
|
74
|
+
!identifiers.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param name [Symbol, String] property to remove
|
|
78
|
+
# @return [Versponse::Properties::Base] the removed property
|
|
79
|
+
# @raise [Versponse::DefinitionError] when the property does not exist or is an identifier
|
|
80
|
+
def remove(name)
|
|
81
|
+
check_not_identifier!(name.to_s)
|
|
82
|
+
properties.delete(name.to_s) || raise(DefinitionError, "Property #{name} not found in #{label}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param view [Versponse::View] view to add or replace
|
|
86
|
+
# @return [Versponse::View] the view
|
|
87
|
+
# @raise [Versponse::DefinitionError] for the implicit default and collapsed views, or a view the version already declares
|
|
88
|
+
def add_view(view)
|
|
89
|
+
raise DefinitionError, "The #{view.name} view is implicit and can not be declared (#{label})" if %i[default collapsed].include?(view.name)
|
|
90
|
+
raise DefinitionError, "#{label} declares view #{view.name} twice" if views[view.name]&.declared?
|
|
91
|
+
|
|
92
|
+
views[view.name] = view
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param other [Version] version to copy from
|
|
96
|
+
# @param exclude_properties [Array<Symbol, String>] properties left out of the copy
|
|
97
|
+
# @return [void]
|
|
98
|
+
# @raise [Versponse::DefinitionError] when an excluded property does not exist in other, or is an identifier
|
|
99
|
+
def copy_from(other, exclude_properties: [])
|
|
100
|
+
excluded = exclude_properties.map(&:to_s)
|
|
101
|
+
excluded.each { |key| raise DefinitionError, identifier_message(key) if other.identifiers.key?(key) }
|
|
102
|
+
unknown = excluded - other.properties.keys
|
|
103
|
+
raise DefinitionError, "#{label} excludes properties unknown in #{other.label}: #{unknown.join(", ")}" if unknown.any?
|
|
104
|
+
|
|
105
|
+
@properties = other.properties.except(*excluded)
|
|
106
|
+
@views = other.views.transform_values(&:for_next_version)
|
|
107
|
+
@identifiers = other.identifiers.dup
|
|
108
|
+
@collapse_if = other.collapse_if
|
|
109
|
+
@inherited_identifiers = true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Checks the views and freezes the version
|
|
113
|
+
#
|
|
114
|
+
# @return [Version] self
|
|
115
|
+
# @raise [Versponse::DefinitionError] when a view excludes an unknown property, includes an unknown view
|
|
116
|
+
# or is part of an include cycle
|
|
117
|
+
def finalize!
|
|
118
|
+
# A key declared in this version replaces inherited views' view-only definitions of it
|
|
119
|
+
views.each_value { |view| view.drop_inherited(@declared_keys) unless view.declared? }
|
|
120
|
+
known = properties.keys | views.each_value.flat_map { |view| view.properties.keys }
|
|
121
|
+
views.each_value do |view|
|
|
122
|
+
view.check_excludes!(known, label)
|
|
123
|
+
view.check_includes!(self)
|
|
124
|
+
view.freeze
|
|
125
|
+
end
|
|
126
|
+
properties.freeze
|
|
127
|
+
views.freeze
|
|
128
|
+
identifiers.freeze
|
|
129
|
+
schema_options.freeze
|
|
130
|
+
freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Array<Symbol>] :default, the declared views, and :collapsed when there is an identifier
|
|
134
|
+
def view_names
|
|
135
|
+
identifiers? ? [:default, *views.keys, :collapsed] : [:default, *views.keys]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param view_name [Symbol, String] view name
|
|
139
|
+
# @return [Array<Versponse::Properties::Base>] properties of the view in output order
|
|
140
|
+
# @raise [Versponse::ViewNotFoundError] for an unknown view, or :collapsed without an identifier
|
|
141
|
+
def properties_for(view_name)
|
|
142
|
+
FieldOrder.call(declared_properties_for(view_name.to_sym), identifiers: identifiers.keys)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param view_name [Symbol, String] declared view name
|
|
146
|
+
# @return [Versponse::View] the view
|
|
147
|
+
# @raise [Versponse::ViewNotFoundError] for an unknown view
|
|
148
|
+
def fetch_view(view_name)
|
|
149
|
+
views.fetch(view_name.to_sym) do
|
|
150
|
+
raise ViewNotFoundError, "View #{view_name} not found in #{label}. Available views: #{view_names.join(", ")}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [String] "<class name> <version name>"
|
|
155
|
+
def label
|
|
156
|
+
"#{@owner} #{name}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# Excluding it would leave the collapsed shape rendering it
|
|
162
|
+
def check_not_identifier!(key)
|
|
163
|
+
raise DefinitionError, identifier_message(key) if identifiers.key?(key)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def identifier_message(key)
|
|
167
|
+
"#{label}: #{key} is an identifier; redeclare the identifiers instead of excluding it"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def declared_properties_for(view_name)
|
|
171
|
+
return properties.values if view_name == :default
|
|
172
|
+
return collapsed_properties if view_name == :collapsed
|
|
173
|
+
|
|
174
|
+
fetch_view(view_name).resolve(self)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def collapsed_properties
|
|
178
|
+
raise ViewNotFoundError, "#{label} has no identifier, so it has no collapsed view. Available views: #{view_names.join(", ")}" unless identifiers?
|
|
179
|
+
|
|
180
|
+
identifiers.values
|
|
181
|
+
end
|
|
182
|
+
end
|
|
5
183
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Resolves a requested version to the closest declared version at or before it
|
|
5
|
+
# Versions are compared as Strings, so they must sort lexicographically (dates like YYYY-MM do).
|
|
6
|
+
# A custom resolver must keep this closest-earlier behaviour, because requested versions are
|
|
7
|
+
# canonicalized against every declared name before caching.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# Versponse::VersionResolver.call("2025-04", %w[2025-01 2025-06]) # => "2025-01"
|
|
11
|
+
class VersionResolver
|
|
12
|
+
# @param requested_version [String] version asked for
|
|
13
|
+
# @param versions [Array<String>] declared versions, oldest first
|
|
14
|
+
# @return [String, nil] the resolved version, or nil when the request is older than every version
|
|
15
|
+
def self.call(requested_version, versions)
|
|
16
|
+
# An index loop: reverse_each.find would allocate an Enumerator on every request
|
|
17
|
+
index = versions.size - 1
|
|
18
|
+
while index >= 0
|
|
19
|
+
version = versions[index]
|
|
20
|
+
return version if version <= requested_version
|
|
21
|
+
|
|
22
|
+
index -= 1
|
|
23
|
+
end
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Versions of one response class, plus class-level description and schema options
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class Versions
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
# @return [String, nil] description used by versions that set none
|
|
11
|
+
attr_accessor :description
|
|
12
|
+
# @return [Hash{String => Object}] schema options merged into every version
|
|
13
|
+
attr_accessor :schema_options
|
|
14
|
+
# @return [Array<String>] declared version names, oldest first
|
|
15
|
+
attr_reader :sorted_names
|
|
16
|
+
|
|
17
|
+
# @param owner [String] response class name, for messages
|
|
18
|
+
# @return [Versions] an empty collection
|
|
19
|
+
def initialize(owner: "AnonymousResponse")
|
|
20
|
+
@owner = owner
|
|
21
|
+
@versions = {}
|
|
22
|
+
@sorted_names = [].freeze
|
|
23
|
+
@schema_options = {}
|
|
24
|
+
@resolved = Cache.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param version [Versponse::Version] finalized version
|
|
28
|
+
# @return [void]
|
|
29
|
+
# @raise [Versponse::DefinitionError] when the class already declares the version
|
|
30
|
+
def add(version)
|
|
31
|
+
raise DefinitionError, "#{@owner} declares version #{version.name} twice" if @versions.key?(version.name)
|
|
32
|
+
|
|
33
|
+
@versions[version.name] = version
|
|
34
|
+
@sorted_names = @versions.keys.sort.freeze
|
|
35
|
+
Registry.add_version_name(version.name)
|
|
36
|
+
clear_resolved
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param name [String] exact declared version name
|
|
40
|
+
# @return [Versponse::Version] the version
|
|
41
|
+
# @raise [Versponse::VersionNotFoundError] when no version has that exact name
|
|
42
|
+
def fetch(name)
|
|
43
|
+
@versions.fetch(name) { raise VersionNotFoundError, "Version #{name} not declared. Declared versions: #{sorted_names.join(", ")}" }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @yieldparam version [Versponse::Version]
|
|
47
|
+
# @return [void]
|
|
48
|
+
def each(&)
|
|
49
|
+
@versions.each_value(&)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] true when no version is declared
|
|
53
|
+
def empty?
|
|
54
|
+
@versions.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Resolutions are memoized by name, so pass the class's own range on every call
|
|
58
|
+
# (supported_versions clears the memo through Versponse.reset_caches!). Raising stores nothing.
|
|
59
|
+
#
|
|
60
|
+
# @param name [String] requested version
|
|
61
|
+
# @param supported [Versponse::SupportedVersions, nil] range declared with supported_versions
|
|
62
|
+
# @return [Versponse::Version] closest declared version at or before the request
|
|
63
|
+
# @raise [Versponse::VersionNotFoundError] when there is none, or the range leaves the request out
|
|
64
|
+
# @raise [Versponse::DefinitionError] when the range's max sorts before every declared version
|
|
65
|
+
def resolve(name, supported: nil)
|
|
66
|
+
name = name.to_s
|
|
67
|
+
@resolved.fetch(name) do
|
|
68
|
+
raise VersionNotFoundError, "Version #{name} is outside supported_versions (#{supported}) of #{@owner}" if supported && !supported?(canonical_name(name), supported)
|
|
69
|
+
|
|
70
|
+
@versions[Versponse.configuration.version_resolver.call(name, sorted_names)] ||
|
|
71
|
+
raise(VersionNotFoundError, "Version #{name} not found. Available versions: #{sorted_names.join(", ")}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Checks the requested name only; declared blocks may lie outside the range
|
|
76
|
+
#
|
|
77
|
+
# @param name [String] requested version name
|
|
78
|
+
# @param supported [Versponse::SupportedVersions, nil] range declared with supported_versions
|
|
79
|
+
# @return [Boolean] true without a range or declared versions, otherwise whether the range includes name
|
|
80
|
+
# @raise [Versponse::DefinitionError] when the range's max sorts before every declared version
|
|
81
|
+
def supported?(name, supported)
|
|
82
|
+
return true if supported.nil? || empty?
|
|
83
|
+
|
|
84
|
+
supported.check!(sorted_names, @owner)
|
|
85
|
+
supported.covers?(name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Names eager_load! compiles: the declared versions inside the range plus min (so a min: "preview"
|
|
89
|
+
# class whose only block is 2025-09 compiles that block as "preview"), canonicalized like serialize
|
|
90
|
+
# does, keeping those that resolve to a declared version
|
|
91
|
+
#
|
|
92
|
+
# @param supported [Versponse::SupportedVersions, nil] range declared with supported_versions
|
|
93
|
+
# @return [Array<String>] canonical version names
|
|
94
|
+
# @raise [Versponse::DefinitionError] when the range's max sorts before every declared version
|
|
95
|
+
def supported_names(supported)
|
|
96
|
+
return sorted_names if supported.nil? || empty?
|
|
97
|
+
|
|
98
|
+
supported.check!(sorted_names, @owner)
|
|
99
|
+
candidates = [*sorted_names, supported.min].compact.map { |name| canonical_name(name) }.uniq
|
|
100
|
+
candidates.select { |name| supported.covers?(name) && Versponse.configuration.version_resolver.call(name, sorted_names) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [void]
|
|
104
|
+
def clear_resolved
|
|
105
|
+
# A new object, not clear: the caller may be inside a resolution holding the old cache's lock
|
|
106
|
+
@resolved = Cache.new
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# The name serialize and to_schema cache under, so resolve("2026-04") and serialize(version: "2026-04") agree
|
|
112
|
+
def canonical_name(name)
|
|
113
|
+
Registry.canonical(name)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# A named selection of a version's properties: include_view, exclude_properties and view-only properties
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class View
|
|
8
|
+
# @return [Symbol] view name
|
|
9
|
+
attr_reader :name
|
|
10
|
+
# @return [Hash{String => Versponse::Properties::Base}] properties declared only in this view
|
|
11
|
+
attr_reader :properties
|
|
12
|
+
|
|
13
|
+
# @param name [Symbol, String] view name
|
|
14
|
+
# @param operations [Array<Array>] [type, argument] pairs in declaration order
|
|
15
|
+
# @param properties [Hash] view-only properties
|
|
16
|
+
# @param inherited [Boolean] true when copied from an earlier version
|
|
17
|
+
def initialize(name:, operations: [], properties: {}, inherited: false)
|
|
18
|
+
@name = name.to_sym
|
|
19
|
+
@operations = operations
|
|
20
|
+
@properties = properties
|
|
21
|
+
@inherited = inherited
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param property [Versponse::Properties::Base] view-only property
|
|
25
|
+
# @return [void]
|
|
26
|
+
def add(property)
|
|
27
|
+
properties[property.key] = property
|
|
28
|
+
@operations << [:add, property.key]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param name [Symbol, String] view whose operations are applied here
|
|
32
|
+
# @return [void]
|
|
33
|
+
# @raise [Versponse::DefinitionError] for :default, which every view already starts from
|
|
34
|
+
def include_view(name)
|
|
35
|
+
raise DefinitionError, "include_view :default is implicit, every view starts from default" if name.to_sym == :default
|
|
36
|
+
|
|
37
|
+
@operations << [:include, name.to_sym]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param names [Array<Symbol, String>] properties to remove
|
|
41
|
+
# @return [void]
|
|
42
|
+
def exclude(names)
|
|
43
|
+
@operations << [:exclude, names.map(&:to_s)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param keys [Array<String>] keys the version declares itself
|
|
47
|
+
# @return [void]
|
|
48
|
+
def drop_inherited(keys)
|
|
49
|
+
@operations = @operations.reject { |type, argument| type == :add && keys.include?(argument) }
|
|
50
|
+
@properties = @properties.except(*keys)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Boolean] true when declared in its version, false when inherited from an earlier one
|
|
54
|
+
def declared?
|
|
55
|
+
!@inherited
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [View] a copy for a later version, whose unknown excludes are ignored
|
|
59
|
+
def for_next_version
|
|
60
|
+
View.new(name:, operations: @operations.dup, properties: @properties.dup, inherited: true)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param known [Array<String>] property keys known in the declaring version
|
|
64
|
+
# @param label [String] version label for the error message
|
|
65
|
+
# @return [nil]
|
|
66
|
+
# @raise [Versponse::DefinitionError] when a declared (not inherited) view excludes an unknown property
|
|
67
|
+
def check_excludes!(known, label)
|
|
68
|
+
return if @inherited
|
|
69
|
+
|
|
70
|
+
@operations.each do |type, argument|
|
|
71
|
+
next unless type == :exclude
|
|
72
|
+
|
|
73
|
+
unknown = argument - known
|
|
74
|
+
raise DefinitionError, "View #{name} in #{label} excludes unknown properties: #{unknown.join(", ")}" if unknown.any?
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param version [Versponse::Version] version the view belongs to
|
|
80
|
+
# @param chain [Array<Symbol>] views being checked, for cycle detection
|
|
81
|
+
# @return [nil]
|
|
82
|
+
# @raise [Versponse::DefinitionError] when the view includes an unknown view or is part of an include cycle
|
|
83
|
+
def check_includes!(version, chain = [])
|
|
84
|
+
raise DefinitionError, "include_view cycle in #{version.label}: #{[*chain, name].join(" -> ")}" if chain.include?(name)
|
|
85
|
+
|
|
86
|
+
@operations.each do |type, argument|
|
|
87
|
+
next unless type == :include
|
|
88
|
+
|
|
89
|
+
included = version.views.fetch(argument) { raise DefinitionError, "View #{name} in #{version.label} includes unknown view #{argument}" }
|
|
90
|
+
included.check_includes!(version, [*chain, name])
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Includes are checked by {#check_includes!} when the version is finalized
|
|
96
|
+
#
|
|
97
|
+
# @param version [Versponse::Version] version the view belongs to
|
|
98
|
+
# @return [Array<Versponse::Properties::Base>] resolved properties in output order
|
|
99
|
+
def resolve(version)
|
|
100
|
+
apply(version, version.properties.dup).values
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [View] self, with operations and properties frozen
|
|
104
|
+
def freeze
|
|
105
|
+
@operations.freeze
|
|
106
|
+
@properties.freeze
|
|
107
|
+
super
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
protected
|
|
111
|
+
|
|
112
|
+
# @param version [Versponse::Version] version the view belongs to
|
|
113
|
+
# @param result [Hash] properties being built
|
|
114
|
+
# @return [Hash] result
|
|
115
|
+
def apply(version, result)
|
|
116
|
+
@operations.each do |type, argument|
|
|
117
|
+
case type
|
|
118
|
+
when :add then result[argument] = properties.fetch(argument)
|
|
119
|
+
when :include then version.fetch_view(argument).apply(version, result)
|
|
120
|
+
when :exclude then argument.each { |key| result.delete(key) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# root:/meta: wrapping of rendered output and its schema
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module Wrapper
|
|
8
|
+
# @param meta [Object, nil] meta: given for a wrapper schema
|
|
9
|
+
# @return [void]
|
|
10
|
+
# @raise [Versponse::DefinitionError] when meta isn't a class with a $ref, such as the Hash serialize takes
|
|
11
|
+
def self.check_meta!(meta)
|
|
12
|
+
return if meta.nil? || meta.respond_to?(:to_ref)
|
|
13
|
+
|
|
14
|
+
raise DefinitionError, %(meta: takes a response class, whose $ref describes "meta"; serialize(meta:) takes the Hash)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The envelope is strict (additionalProperties: false) whatever config.default_additional_properties says
|
|
18
|
+
#
|
|
19
|
+
# @param klass [Class] response class
|
|
20
|
+
# @param view [Symbol] view name
|
|
21
|
+
# @param root [String, Symbol] key holding the response
|
|
22
|
+
# @param meta [Class, nil] class whose $ref describes "meta"
|
|
23
|
+
# @param collection [Boolean] the root key holds an array
|
|
24
|
+
# @return [Hash] wrapper schema
|
|
25
|
+
def self.schema(klass, view:, root:, meta:, collection:)
|
|
26
|
+
item = {"$ref" => klass.to_ref(view:)}
|
|
27
|
+
properties = {root.to_s => collection ? {"type" => "array", "items" => item} : item}
|
|
28
|
+
properties["meta"] = {"$ref" => meta.to_ref} if meta
|
|
29
|
+
{"type" => "object", "required" => properties.keys, "properties" => properties, "additionalProperties" => false}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param data [Hash, Array<Hash>] rendered output
|
|
33
|
+
# @param root [String, Symbol, nil] wraps data under this key
|
|
34
|
+
# @param meta [Hash, nil] added under "meta" with String keys
|
|
35
|
+
# @return [Hash, Array<Hash>] data, or the wrapper Hash
|
|
36
|
+
def self.wrap(data, root:, meta:)
|
|
37
|
+
return data if root.nil?
|
|
38
|
+
|
|
39
|
+
wrapped = {root.to_s => data}
|
|
40
|
+
wrapped["meta"] = string_keys(meta) if meta
|
|
41
|
+
wrapped
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.string_keys(value)
|
|
45
|
+
case value
|
|
46
|
+
when Hash then value.to_h { |key, item| [key.to_s, string_keys(item)] }
|
|
47
|
+
when Array then value.map { |item| string_keys(item) }
|
|
48
|
+
else value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :string_keys
|
|
52
|
+
end
|
|
53
|
+
end
|