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