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,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
@@ -1,5 +1,183 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Versponse
4
- VERSION = "0.1.0"
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