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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Properties
5
+ # A scalar value
6
+ #
7
+ # @api private
8
+ class Field < Base
9
+ # @return [Boolean] true: nil values can be replaced
10
+ def self.default_option? = true
11
+
12
+ # @param type [Symbol, String, Array<Symbol, String>] a built-in type ({FieldType::BUILT_IN}; "object" is a free-form map whose
13
+ # value passes through), a custom one from config.custom_field_types, or a union of two or more built-in types
14
+ # @param options [Hash] see {Base#initialize}; they win over the custom type's schema options
15
+ # @raise [Versponse::DefinitionError] for an unknown type or an invalid union
16
+ def initialize(type:, **options)
17
+ super(**options)
18
+ @type, type_options = FieldType.resolve(type, key)
19
+ @schema_options = type_options.merge(schema_options)
20
+ check_default!
21
+ end
22
+
23
+ # @param context [Versponse::SchemaContext, nil] schema generation context
24
+ # @return [Hash] JSON Schema of the field; a type union is a type array, or an anyOf of single types in
25
+ # OpenAPI 3.0 documents, with keywords on the outer node and nullable on each branch. nullable: never
26
+ # changes a declared any_of, so a nullable field's any_of must list {"type" => "null"} itself.
27
+ def schema(context)
28
+ options = (nullable? && schema_options["enum"]) ? schema_options.merge("enum" => schema_options["enum"] | [nil]) : schema_options
29
+ return nullable_type({"type" => @type}.merge(options), context) unless openapi30_union?(@type, context)
30
+
31
+ FieldType.openapi30_union(@type, options, nullable: nullable?)
32
+ end
33
+
34
+ # @param version [String] unused by scalars
35
+ # @param formatters [Hash{String => #call}] formatters by JSON Schema format, for the compiling class and version
36
+ # @param policies [Versponse::ClassPolicies] class-level on_nil and on_error of the compiling class
37
+ # @return [Versponse::Steps::Value] compiled step with the formatter of its format
38
+ def step(version:, formatters:, policies: ClassPolicies::NONE)
39
+ value_step(formatter: formatters[schema_options["format"]], policies:)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Properties
5
+ # The unnamed one_of inside a collection block: types each item of the collection
6
+ #
7
+ # @api private
8
+ class ItemOneOf < OneOf
9
+ # @return [Boolean] false: the collection's context: covers the items
10
+ def self.context_option? = false
11
+
12
+ # @param _block [Proc] unused
13
+ # @raise [Versponse::DefinitionError] always, the collection owns the value source
14
+ def source=(_block)
15
+ raise DefinitionError, "#{key}: source belongs to the collection, declare it outside the unnamed one_of"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Properties
5
+ # An inline object; its source is read by name or with map: ("." for the same object)
6
+ #
7
+ # @api private
8
+ class Object < Base
9
+ # @return [Array<String>] keywords this property type writes itself
10
+ def self.written_keywords = %w[properties required].freeze
11
+
12
+ # @return [Boolean] true: the subtree can render with its own context
13
+ def self.context_option? = true
14
+
15
+ include Container
16
+
17
+ # @param context [Versponse::SchemaContext] schema generation context
18
+ # @return [Hash] object schema
19
+ def schema(context)
20
+ nullable_type(inline_schema(context).merge(schema_options), context)
21
+ end
22
+
23
+ # @param version [String] requested version name
24
+ # @param formatters [Hash{String => #call}] formatters by JSON Schema format, for the compiling class and version
25
+ # @param policies [Versponse::ClassPolicies] class-level on_nil and on_error of the compiling class
26
+ # @return [Versponse::Steps::Nested] compiled step with an inline child plan
27
+ def step(version:, formatters:, policies: ClassPolicies::NONE)
28
+ Steps::Nested.new(key:, extractor:, condition:, plan: inline_plan(version, formatters, policies), collection: false, context_override:)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Properties
5
+ # A polymorphic value whose variants are other response classes, picked with match: (===)
6
+ #
7
+ # @api private
8
+ class OneOf < Base
9
+ # @return [Array<String>] keywords this property type writes itself
10
+ def self.written_keywords = %w[oneOf anyOf discriminator].freeze
11
+
12
+ # @return [Boolean] true: the subtree can render with its own context
13
+ def self.context_option? = true
14
+
15
+ # One variant: response class, matcher, view and context override
16
+ Variant = Data.define(:from, :match, :view, :context_override)
17
+
18
+ # @param discriminator [String, Symbol, nil] OpenAPI discriminator property
19
+ # @param expandable [Boolean] render variants collapsed unless expanded
20
+ # @param options [Hash] see {Base#initialize}
21
+ def initialize(discriminator: nil, expandable: false, **options)
22
+ super(**options)
23
+ @discriminator = discriminator&.to_s
24
+ @expandable = expandable
25
+ @variants = []
26
+ end
27
+
28
+ # @param _property [Versponse::Properties::Base] unused
29
+ # @raise [Versponse::DefinitionError] always, variants are declared with reference
30
+ def add(_property)
31
+ raise DefinitionError, "#{key}: declare one_of variants with reference from:, match:"
32
+ end
33
+
34
+ # @param from [Class] Versponse response class
35
+ # @param match [Class, Proc, Array<Class, Proc>] compared with === against the value
36
+ # @param view [Symbol] view of from
37
+ # @param context [Hash, #call, nil] the variant's context: (a callable gets the matched value)
38
+ # @return [void]
39
+ # @raise [Versponse::DefinitionError] without match: or with a non-Versponse class
40
+ def add_variant(from:, match:, view: :default, context: nil)
41
+ raise DefinitionError, "#{key}: a one_of variant needs match:" if match.nil?
42
+ raise DefinitionError, "#{key}: one_of variants must be Versponse response classes" unless References.versponse?(from)
43
+
44
+ @variants << Variant.new(from:, match:, view: view.to_sym, context_override: ContextOverride.build(context, key:))
45
+ end
46
+
47
+ # @return [Boolean] true when at least one variant is declared
48
+ def variants?
49
+ !@variants.empty?
50
+ end
51
+
52
+ # @return [Boolean] true when variants render collapsed unless expanded
53
+ def expandable?
54
+ @expandable
55
+ end
56
+
57
+ # @param context [Versponse::SchemaContext] schema generation context
58
+ # @return [Hash] oneOf schema (anyOf of full and collapsed forms when expandable); the discriminator,
59
+ # with a mapping built from each variant's const, is left out of validation schemas and of expandable
60
+ # one_of schemas (a collapsed payload carries the same value as the full form, so a mapping would
61
+ # send it to the full schema)
62
+ # A nullable one_of with a documented discriminator keeps null outside the discriminated oneOf, which
63
+ # would otherwise reject it.
64
+ # @raise [Versponse::DefinitionError] when a variant lacks the discriminator const or two variants share a value
65
+ def schema(context)
66
+ schemas = @variants.flat_map { |variant| variant_schemas(variant, context) }
67
+ keyword = expandable? ? "anyOf" : "oneOf"
68
+ schema = if @discriminator && !expandable? && !context.validation?
69
+ discriminator = {"propertyName" => @discriminator, "mapping" => discriminator_mapping(context.version)}
70
+ nullable_ref({keyword => schemas, "discriminator" => discriminator}, context)
71
+ else
72
+ nullable_choice(keyword, schemas, context)
73
+ end
74
+ SchemaMerge.call(schema, schema_options)
75
+ end
76
+
77
+ # @param version [String] canonical version name
78
+ # @return [Versponse::VariantDispatcher] matching to the variants' full plans
79
+ def dispatcher(version)
80
+ variants = @variants.map { |variant| [Array(variant.match).freeze, with_context(variant, References.lazy_plan(variant.from, version:, view: variant.view, key:))] }
81
+ VariantDispatcher.new(variants, nullable: nullable?)
82
+ end
83
+
84
+ # @param version [String] canonical version name
85
+ # @return [Versponse::VariantDispatcher] matching to the variants' collapsed plans (full plans when a variant's collapse_if is false)
86
+ # @raise [Versponse::DefinitionError] when a variant class has no identifier
87
+ def collapsed_dispatcher(version)
88
+ variants = @variants.map { |variant| [Array(variant.match).freeze, with_context(variant, collapsed_variant_plan(variant, version))] }
89
+ VariantDispatcher.new(variants, nullable: nullable?)
90
+ end
91
+
92
+ # @param version [String] canonical version name
93
+ # @param formatters [Hash{String => #call}] unused; variant classes compile with their own
94
+ # @param policies [Versponse::ClassPolicies] class-level on_nil and on_error of the compiling class
95
+ # @return [Versponse::Steps::OneOf] compiled step
96
+ def step(version:, formatters:, policies: ClassPolicies::NONE)
97
+ collapsed_dispatcher = (collapsed_dispatcher(version) if expandable?)
98
+ Steps::OneOf.new(key:, extractor:, condition:, dispatcher: dispatcher(version), collapsed_dispatcher:, context_override:)
99
+ end
100
+
101
+ # @param version [String] canonical version name
102
+ # @param prefix [Array<String>] output keys leading here
103
+ # @param stack [Array<Array(Class, Symbol)>] classes and views being walked
104
+ # @return [Array<Array(Array<String>, Array<Class>)>] this path with every variant's class when expandable,
105
+ # plus every variant's entries
106
+ def expandable_entries(version:, prefix:, stack:)
107
+ path = prefix + [key]
108
+ own = expandable? ? [[path, @variants.map(&:from).uniq]] : []
109
+ own + @variants.flat_map { |variant| References.nested_entries(variant.from, view: variant.view, version:, prefix: path, stack:) }
110
+ end
111
+
112
+ # @return [Boolean] true when expandable
113
+ def expandable_path?
114
+ expandable?
115
+ end
116
+
117
+ # @param version [String] canonical version name
118
+ # @return [Array<Versponse::Properties::Base>] properties of every variant's view
119
+ def path_children(version)
120
+ @variants.flat_map { |variant| variant.from.versponse.resolve(version).properties_for(variant.view) }
121
+ end
122
+
123
+ # @return [Array<Array(Class, Symbol, String)>] every variant's class and view
124
+ def reference_targets
125
+ @variants.filter_map { |variant| [variant.from, variant.view, key] if References.versponse?(variant.from) }
126
+ end
127
+
128
+ private
129
+
130
+ def with_context(variant, plan)
131
+ variant.context_override ? ContextPlan.new(plan, variant.context_override) : plan
132
+ end
133
+
134
+ def variant_schemas(variant, context)
135
+ full = References.schema(variant.from, view: variant.view, property: nil, context:, key:)
136
+ expandable? ? [full, collapsed_variant_schema(variant, context)] : [full]
137
+ end
138
+
139
+ def collapsed_variant_schema(variant, context)
140
+ References.ensure_identifier!(variant.from, context.version)
141
+ return References.schema(variant.from, view: :collapsed, property: nil, context:) unless @discriminator
142
+
143
+ schema = variant.from.versponse.object_schema(version: context.version, view: :collapsed, context:)
144
+ discriminator = {@discriminator => discriminator_property(variant, context.version).schema(context)}
145
+ schema.merge("required" => [@discriminator, *schema["required"]], "properties" => discriminator.merge(schema["properties"]))
146
+ end
147
+
148
+ # Decides per value: an item one_of lets each item pick its own variant and shape
149
+ def collapsed_variant_plan(variant, version)
150
+ plan = References.collapsed_plan(variant.from, version)
151
+ plan = DiscriminatedPlan.new(plan, key: @discriminator, value: discriminator_property(variant, version).value) if @discriminator
152
+ collapse_if = References.collapse_if(variant.from, version)
153
+ return plan unless collapse_if
154
+
155
+ CollapseIfPlan.new(collapse_if, collapsed: plan, full: LazyPlan.new(variant.from, version:, view: variant.view))
156
+ end
157
+
158
+ def discriminator_mapping(version)
159
+ @variants.each_with_object({}) do |variant, mapping|
160
+ value = discriminator_property(variant, version).value.to_s
161
+ raise DefinitionError, "#{key}: two variants share the discriminator value #{value.inspect}" if mapping.key?(value)
162
+
163
+ mapping[value] = variant.from.to_ref(view: variant.view)
164
+ end
165
+ end
166
+
167
+ def discriminator_property(variant, version)
168
+ property = variant.from.versponse.resolve(version).properties_for(variant.view).find { |candidate| candidate.key == @discriminator }
169
+ raise DefinitionError, "#{key}: #{variant.from.name} has no const #{@discriminator} for the discriminator" if property.nil?
170
+ raise DefinitionError, "#{key}: #{variant.from.name}'s #{@discriminator} must be a const for the discriminator" unless property.is_a?(Const)
171
+
172
+ property
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Properties
5
+ # Another response class rendered in place, or a non-Versponse schema whose value passes through.
6
+ # With property:, a Versponse class's property is reused as if it were declared here.
7
+ #
8
+ # @api private
9
+ class Reference < Base
10
+ # @return [Boolean] true: the subtree can render with its own context
11
+ def self.context_option? = true
12
+
13
+ # @param from [Class] class implementing the reference protocol
14
+ # @param view [Symbol] view of from
15
+ # @param property [Symbol, nil] reuse one property of from instead of the whole class
16
+ # @param expandable [Boolean] render collapsed unless expanded
17
+ # @param options [Hash] see {Base#initialize}
18
+ # @raise [Versponse::DefinitionError] for an invalid from:, nullable: with a versponse property:,
19
+ # expandable: on a foreign class or with property:, or context: with a non-Versponse class
20
+ def initialize(from:, view: :default, property: nil, expandable: false, **options)
21
+ super(**options)
22
+ raise DefinitionError, "#{key}: from: must respond to to_ref and to_validation_schema" unless from.respond_to?(:to_ref) && from.respond_to?(:to_validation_schema)
23
+
24
+ @from = from
25
+ @view = view.to_sym
26
+ @property = property&.to_s
27
+ @expandable = expandable
28
+ raise DefinitionError, "#{key}: nullable comes from the referenced property" if versponse_property? && nullable?
29
+ raise DefinitionError, "#{key}: expandable: needs a Versponse class without property:" if expandable && (!References.versponse?(from) || @property)
30
+ raise DefinitionError, "#{key}: context: needs a Versponse class to render with it" if context_override && !References.versponse?(@from)
31
+ end
32
+
33
+ # @return [Boolean] true when the reference renders collapsed unless expanded
34
+ def expandable?
35
+ @expandable
36
+ end
37
+
38
+ # @param context [Versponse::SchemaContext] schema generation context
39
+ # @return [Hash] $ref or inlined schema (anyOf full/collapsed when expandable); keywords sit on the outer node,
40
+ # beside an allOf around a bare $ref in OpenAPI 3.0 documents, which ignore $ref siblings
41
+ # @raise [Versponse::DefinitionError] when a property: does not exist
42
+ def schema(context)
43
+ return with_options(referenced_property_schema(context), context) if versponse_property?
44
+
45
+ check_foreign_property!(context.version) if @property
46
+ return SchemaMerge.call(nullable_choice("anyOf", References.expandable_schemas(@from, view: @view, context:, key:), context), schema_options) if expandable?
47
+
48
+ with_options(nullable_ref(References.schema(@from, view: @view, property: @property, context:, key:), context), context)
49
+ end
50
+
51
+ # @param version [String] canonical version name
52
+ # @param formatters [Hash{String => #call}] formatters of the compiling class, used by a property: reference;
53
+ # a nested class compiles with its own
54
+ # @param policies [Versponse::ClassPolicies] class-level on_nil and on_error of the compiling class
55
+ # @return [Versponse::Steps::Nested, Versponse::Steps::Value, #call] compiled step
56
+ # @raise [Versponse::DefinitionError] when a versponse property: does not exist
57
+ def step(version:, formatters:, policies: ClassPolicies::NONE)
58
+ return referenced_property(version).step(version:, formatters:, policies:) if versponse_property?
59
+ return value_step unless References.versponse?(@from)
60
+
61
+ collapsed_plan = (References.collapsed_plan(@from, version) if expandable?)
62
+ collapse_if = (References.collapse_if(@from, version) if expandable?)
63
+ Steps::Nested.new(key:, extractor:, condition:, plan: References.lazy_plan(@from, version:, view: @view, key:), collapsed_plan:, collapse_if:, collection: false, context_override:)
64
+ end
65
+
66
+ # @param version [String] canonical version name
67
+ # @param prefix [Array<String>] output keys leading here
68
+ # @param stack [Array<Array(Class, Symbol)>] classes and views being walked
69
+ # @return [Array<Array(Array<String>, Array<Class>)>] this path with from: when expandable, plus the
70
+ # referenced class's entries
71
+ def expandable_entries(version:, prefix:, stack:)
72
+ return referenced_property(version).expandable_entries(version:, prefix:, stack:) if versponse_property?
73
+
74
+ path = prefix + [key]
75
+ (expandable? ? [[path, [@from]]] : []) + References.nested_entries(@from, view: @view, version:, prefix: path, stack:)
76
+ end
77
+
78
+ # @return [Boolean] true when expandable
79
+ def expandable_path?
80
+ expandable?
81
+ end
82
+
83
+ # @param version [String] canonical version name
84
+ # @return [Array<Versponse::Properties::Base>] properties of the referenced class's view
85
+ def path_children(version)
86
+ References.versponse?(@from) ? @from.versponse.resolve(version).properties_for(@view) : []
87
+ end
88
+
89
+ # @param version [String] canonical version name
90
+ # @return [Versponse::Properties::Base] the reused copy for a versponse property:, otherwise self
91
+ def path_target(version)
92
+ versponse_property? ? referenced_property(version) : self
93
+ end
94
+
95
+ # @return [Array<Array(Class, Symbol, String)>] the referenced Versponse class and view (none for property:)
96
+ def reference_targets
97
+ (References.versponse?(@from) && @property.nil?) ? [[@from, @view, key]] : []
98
+ end
99
+
100
+ private
101
+
102
+ def versponse_property?
103
+ !@property.nil? && References.versponse?(@from)
104
+ end
105
+
106
+ def referenced_property(version)
107
+ original = @from.versponse.resolve(version).properties_for(@view).find { |property| property.key == @property } ||
108
+ raise(DefinitionError, "#{key}: property #{@property} not found in #{@from.name} #{version}/#{@view}")
109
+ original.renamed(name:, map:, block:, condition:, context_override:)
110
+ end
111
+
112
+ # OpenAPI 3.0 ignores keywords next to a $ref, so a bare $ref with keywords is wrapped in allOf
113
+ def with_options(schema, context)
114
+ schema = {"allOf" => [schema]} if schema.key?("$ref") && schema_options.any? && nullable_keyword?(context)
115
+ SchemaMerge.call(schema, schema_options)
116
+ end
117
+
118
+ # A foreign class (a verquest request) answers nil for a property it doesn't have
119
+ def check_foreign_property!(version)
120
+ return unless @from.to_validation_schema(version:, property: @property).nil?
121
+
122
+ raise DefinitionError, "#{key}: property #{@property} not found in #{@from.name} #{version}"
123
+ end
124
+
125
+ def referenced_property_schema(context)
126
+ copy = referenced_property(context.version)
127
+ return {"$ref" => @from.to_ref(view: @view, property: @property)} unless context.validation?
128
+
129
+ copy.schema(context)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Schemas of referenced classes: Versponse classes, or any class implementing the reference
5
+ # protocol (to_ref(property:) and to_validation_schema(version:, property:)), such as verquest requests.
6
+ # A foreign class that references back to the class being generated (A -> verquest -> A) re-enters
7
+ # A's schema cache and raises ThreadError; such cross-gem cycles are not supported.
8
+ #
9
+ # @api private
10
+ module References
11
+ # @param klass [Object] referenced class
12
+ # @return [Boolean] true for Versponse response classes
13
+ def self.versponse?(klass)
14
+ klass.is_a?(Class) && klass < Base
15
+ end
16
+
17
+ # Checks a referenced Versponse class serves the version and view, so a definition mistake raises
18
+ # DefinitionError wherever the reference compiles (serialize, schemas, eager_load!, documents)
19
+ #
20
+ # @param klass [Class] referenced class (anything but a Versponse class passes)
21
+ # @param version [String] canonical version name
22
+ # @param view [Symbol] referenced view
23
+ # @param key [String] referencing property, for the message
24
+ # @return [void]
25
+ # @raise [Versponse::DefinitionError] when the class declares nothing at or before the version, or lacks the view
26
+ # @raise [Versponse::VersionNotFoundError] when its supported_versions leave the version out (a runtime answer)
27
+ def self.check_target!(klass, version:, view:, key:)
28
+ return unless versponse?(klass)
29
+
30
+ definition = target_version(klass, version, key)
31
+ return if definition.view_names.include?(view.to_sym)
32
+
33
+ raise DefinitionError, "#{key}: view #{view} not found in #{klass.name || "AnonymousResponse"} #{version}"
34
+ end
35
+
36
+ def self.target_version(klass, version, key)
37
+ klass.versponse.resolve(version)
38
+ rescue VersionNotFoundError
39
+ raise if Versponse.configuration.version_resolver.call(Registry.canonical(version), klass.versponse.versions.sorted_names)
40
+
41
+ raise DefinitionError, "#{key}: #{klass.name || "AnonymousResponse"} has no version at or before #{version}"
42
+ end
43
+ private_class_method :target_version
44
+
45
+ # @param klass [Class] Versponse response class
46
+ # @param version [String] canonical version name
47
+ # @param view [Symbol] view to render
48
+ # @param key [String] referencing property, for errors
49
+ # @return [Versponse::LazyPlan] the class's plan, compiled on first use
50
+ # @raise [Versponse::DefinitionError] see {.check_target!}
51
+ def self.lazy_plan(klass, version:, view:, key:)
52
+ check_target!(klass, version:, view:, key:)
53
+ LazyPlan.new(klass, version:, view:)
54
+ end
55
+
56
+ # @param klass [Class] referenced class
57
+ # @param view [Symbol] view of a Versponse class
58
+ # @param property [Symbol, nil] property of a non-Versponse class
59
+ # @param context [Versponse::SchemaContext] schema generation context
60
+ # @param key [String, nil] referencing property; given, the target is checked with {.check_target!}
61
+ # @return [Hash] a $ref, or the inlined schema in validation mode
62
+ def self.schema(klass, view:, property:, context:, key: nil)
63
+ return foreign_schema(klass, property:, context:) unless versponse?(klass)
64
+
65
+ check_target!(klass, version: context.version, view:, key:) if key
66
+ return {"$ref" => klass.to_ref(view:)} unless context.validation?
67
+
68
+ context.inline([klass, view], klass.component_name(view:)) { klass.versponse.object_schema(version: context.version, view:, context:) }
69
+ end
70
+
71
+ # @param klass [Class] non-Versponse class implementing the protocol
72
+ # @param property [Symbol, nil] referenced property
73
+ # @param context [Versponse::SchemaContext] schema generation context
74
+ # @return [Hash] a $ref, or its validation schema
75
+ def self.foreign_schema(klass, property:, context:)
76
+ check_dialect!(klass)
77
+ return {"$ref" => klass.to_ref(property:)} unless context.validation?
78
+
79
+ klass.to_validation_schema(version: context.version, property:)
80
+ end
81
+
82
+ # JSON Schema versions that write const, nullability and anchors alike (OpenAPI 3.1 uses the 2020-12 dialect),
83
+ # so verquest and versponse schemas from the same group can be mixed
84
+ COMPATIBLE_VERSIONS = [%i[draft4], %i[openapi30], %i[draft6 draft7], %i[draft2019_09 draft2020_12 openapi31]].freeze
85
+
86
+ # A verquest request's schemas follow verquest's json_schema_version; embedded in versponse documents and
87
+ # validation they must be compatible with versponse's. Checked when schemas are built, so it sees the current config.
88
+ #
89
+ # @param klass [Object] referenced class
90
+ # @return [void]
91
+ # @raise [Versponse::DefinitionError] for a verquest request when the two gems' json_schema_version aren't compatible
92
+ def self.check_dialect!(klass)
93
+ return unless defined?(::Verquest::Base) && klass.is_a?(Class) && klass < ::Verquest::Base
94
+
95
+ theirs = ::Verquest.configuration.json_schema_version
96
+ ours = Versponse.configuration.json_schema_version
97
+ return if COMPATIBLE_VERSIONS.any? { |group| group.include?(theirs) && group.include?(ours) }
98
+
99
+ raise DefinitionError, "#{klass.name} is a verquest request with json_schema_version :#{theirs}, versponse uses :#{ours}; set both to the same"
100
+ end
101
+
102
+ # @param klass [Class] Versponse response class
103
+ # @param version [String] canonical version name
104
+ # @return [void]
105
+ # @raise [Versponse::DefinitionError] when the class has no identifier in that version (a class that
106
+ # lacks the version entirely is left alone, like a plain $ref)
107
+ def self.ensure_identifier!(klass, version)
108
+ definition = klass.versponse.resolve(version)
109
+ rescue VersionNotFoundError
110
+ nil
111
+ else
112
+ return if definition.identifiers?
113
+
114
+ raise DefinitionError, "#{klass.name} has no identifier in #{version}; expandable associations need one"
115
+ end
116
+
117
+ # @param klass [Class] Versponse response class
118
+ # @param version [String] canonical version name
119
+ # @return [Versponse::LazyPlan] plan of the class's collapsed view
120
+ # @raise [Versponse::DefinitionError] when the class has no identifier
121
+ def self.collapsed_plan(klass, version)
122
+ ensure_identifier!(klass, version)
123
+ LazyPlan.new(klass, version:, view: :collapsed)
124
+ end
125
+
126
+ # @param klass [Class] Versponse response class
127
+ # @param version [String] canonical version name
128
+ # @return [#call, nil] the class's identifier collapse_if in that version; nil without one or without the version
129
+ def self.collapse_if(klass, version)
130
+ klass.versponse.resolve(version).collapse_if
131
+ rescue VersionNotFoundError
132
+ nil
133
+ end
134
+
135
+ # @param klass [Class] Versponse response class
136
+ # @param view [Symbol] view of the full form
137
+ # @param context [Versponse::SchemaContext] schema generation context
138
+ # @return [Array(Hash, Hash)] the full and the collapsed schema
139
+ # @raise [Versponse::DefinitionError] when the class has no identifier
140
+ def self.expandable_schemas(klass, view:, context:, key: nil)
141
+ check_target!(klass, version: context.version, view:, key:) if key
142
+ ensure_identifier!(klass, context.version)
143
+ [schema(klass, view:, property: nil, context:), schema(klass, view: :collapsed, property: nil, context:)]
144
+ end
145
+
146
+ # @param klass [Class] referenced class
147
+ # @param view [Symbol] view to walk
148
+ # @param version [String] canonical version name
149
+ # @param prefix [Array<String>] output keys leading here
150
+ # @param stack [Array<Array(Class, Symbol)>] classes and views being walked, to stop recursion
151
+ # @return [Array<Array(Array<String>, Array<Class>)>] expandable paths under the class's view, each with the
152
+ # classes it renders
153
+ def self.nested_entries(klass, view:, version:, prefix:, stack:)
154
+ return [] unless versponse?(klass)
155
+ return [] if stack.include?([klass, view])
156
+
157
+ klass.versponse.resolve(version).properties_for(view).flat_map do |property|
158
+ property.expandable_entries(version:, prefix:, stack: [*stack, [klass, view]])
159
+ end
160
+ end
161
+ end
162
+ end