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,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Property types of the DSL
|
|
5
|
+
module Properties
|
|
6
|
+
# Shared behaviour of every property: output key, value source, condition and schema keywords
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class Base
|
|
10
|
+
# Schema allowing null next to a referenced schema
|
|
11
|
+
NULL_SCHEMA = {"type" => "null"}.freeze
|
|
12
|
+
|
|
13
|
+
# Null in OpenAPI 3.0, which has no null type: validators read nullable only beside a type or enum
|
|
14
|
+
NULL_SCHEMA_30 = {"nullable" => true, "enum" => [nil].freeze}.freeze
|
|
15
|
+
|
|
16
|
+
# JSON Schema and OpenAPI keywords a property takes as options (camelized), besides x- extensions.
|
|
17
|
+
# type, required, nullable, discriminator and $ref come from the DSL itself.
|
|
18
|
+
SCHEMA_KEYWORDS = %w[
|
|
19
|
+
title description default examples example deprecated readOnly writeOnly externalDocs xml $comment
|
|
20
|
+
enum const format pattern minLength maxLength contentMediaType contentEncoding contentSchema
|
|
21
|
+
minimum maximum exclusiveMinimum exclusiveMaximum multipleOf
|
|
22
|
+
items prefixItems contains minContains maxContains minItems maxItems uniqueItems unevaluatedItems
|
|
23
|
+
properties patternProperties additionalProperties unevaluatedProperties propertyNames
|
|
24
|
+
minProperties maxProperties dependentRequired dependentSchemas
|
|
25
|
+
allOf anyOf oneOf not if then else
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# @return [Array<String>] keywords this property type writes itself, so they can't be options
|
|
29
|
+
def self.written_keywords = [].freeze
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] true for properties whose subtree can render with its own context (context: option)
|
|
32
|
+
def self.context_option? = false
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] true for properties whose nil values a default: or the class's on_nil replaces
|
|
35
|
+
def self.default_option? = false
|
|
36
|
+
|
|
37
|
+
# Options the DSL reads itself, suggested for typos
|
|
38
|
+
DSL_OPTIONS = %w[map nullable if unless context default on_error].freeze
|
|
39
|
+
|
|
40
|
+
# @return [Symbol] property name, used to read the value
|
|
41
|
+
attr_reader :name
|
|
42
|
+
# @return [String] key in the output and the schema
|
|
43
|
+
attr_reader :key
|
|
44
|
+
# @return [String, nil] source path given with map:
|
|
45
|
+
attr_reader :map
|
|
46
|
+
# @return [Proc, nil] value block called with (object, context)
|
|
47
|
+
attr_reader :block
|
|
48
|
+
# @return [Versponse::Condition, nil] if:/unless: condition
|
|
49
|
+
attr_reader :condition
|
|
50
|
+
# @return [Hash{String => Object}] camelized JSON Schema keywords
|
|
51
|
+
attr_reader :schema_options
|
|
52
|
+
# @return [Versponse::ContextOverride::Merge, Versponse::ContextOverride::Call, nil] context of the subtree
|
|
53
|
+
attr_reader :context_override
|
|
54
|
+
# @return [Symbol, #call, nil] the property's own on_error: policy
|
|
55
|
+
attr_reader :on_error
|
|
56
|
+
|
|
57
|
+
# @param name [Symbol, String] property name and output key
|
|
58
|
+
# @param block [Proc, nil] value block
|
|
59
|
+
# @param options [Hash] map:, nullable:, if:, unless: and JSON Schema keywords in snake_case
|
|
60
|
+
# @raise [Versponse::DefinitionError] when options conflict
|
|
61
|
+
def initialize(name:, block: nil, **options)
|
|
62
|
+
@name = name.to_sym
|
|
63
|
+
@key = name.to_s.freeze
|
|
64
|
+
@block = block
|
|
65
|
+
@map = options.delete(:map)&.to_s
|
|
66
|
+
@nullable = options.delete(:nullable) || false
|
|
67
|
+
@condition = Condition.build(options.delete(:if), options.delete(:unless))
|
|
68
|
+
context = options.delete(:context)
|
|
69
|
+
raise DefinitionError, "#{key}: context: only works on reference, collection, object and one_of" unless context.nil? || self.class.context_option?
|
|
70
|
+
|
|
71
|
+
@context_override = ContextOverride.build(context, key: @key)
|
|
72
|
+
@on_error = options.delete(:on_error)
|
|
73
|
+
check_on_error! unless @on_error.nil?
|
|
74
|
+
parse_default!(options)
|
|
75
|
+
check_options!(options)
|
|
76
|
+
@schema_options = Naming.camelize_keys(options)
|
|
77
|
+
check_keywords!(options)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] true when the key is emitted only under a condition (so it is not required)
|
|
81
|
+
def conditional?
|
|
82
|
+
!condition.nil?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Boolean] true when null is allowed
|
|
86
|
+
def nullable?
|
|
87
|
+
@nullable
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Sets the value block declared with `source { |object, context| ... }` inside a container
|
|
91
|
+
#
|
|
92
|
+
# @param block [Proc] value block
|
|
93
|
+
# @raise [Versponse::DefinitionError] when map: is also given or source is declared twice
|
|
94
|
+
def source=(block)
|
|
95
|
+
raise DefinitionError, "#{key}: use either source or map:, not both" if map
|
|
96
|
+
raise DefinitionError, "#{key}: source is already declared" if @block
|
|
97
|
+
|
|
98
|
+
@block = block
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A copy under another name, used by `reference property:`. A map: or block replaces the
|
|
102
|
+
# source; without either the copy keeps its own source (its map:, block, or original name).
|
|
103
|
+
#
|
|
104
|
+
# @param name [Symbol, String] new name and output key
|
|
105
|
+
# @param map [String, nil] replacement map:
|
|
106
|
+
# @param block [Proc, nil] replacement value block
|
|
107
|
+
# @param condition [Versponse::Condition, nil] condition of the copy (the original's is dropped)
|
|
108
|
+
# @param context_override [Versponse::ContextOverride::Merge, Versponse::ContextOverride::Call, nil] replaces the
|
|
109
|
+
# copy's context: when given (only for properties that take context:)
|
|
110
|
+
# @return [Versponse::Properties::Base] the copy
|
|
111
|
+
def renamed(name:, map:, block:, condition:, context_override: nil)
|
|
112
|
+
dup.tap { |copy| copy.rename(name:, map:, block:, condition:, context_override:) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @param context [Versponse::SchemaContext] schema generation context
|
|
116
|
+
# @return [Hash] JSON Schema of the property
|
|
117
|
+
def schema(context)
|
|
118
|
+
raise NotImplementedError
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param version [String] requested version name, used by nested classes
|
|
122
|
+
# @param formatters [Hash{String => #call}] formatters by JSON Schema format, for the compiling class and version
|
|
123
|
+
# @param policies [Versponse::ClassPolicies] class-level on_nil and on_error of the compiling class
|
|
124
|
+
# @return [#call] compiled plan step
|
|
125
|
+
def step(version:, formatters:, policies: ClassPolicies::NONE)
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @param version [String] canonical version name
|
|
130
|
+
# @param prefix [Array<String>] output keys leading to this property
|
|
131
|
+
# @param stack [Array<Array(Class, Symbol)>] classes and views being walked, to stop recursion
|
|
132
|
+
# @return [Array<Array(Array<String>, Array<Class>)>] expandable paths under this property, each with the
|
|
133
|
+
# classes it renders
|
|
134
|
+
def expandable_entries(version:, prefix:, stack:)
|
|
135
|
+
[]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Boolean] true when a request may expand the association under this property
|
|
139
|
+
def expandable_path?
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @param version [String] canonical version name
|
|
144
|
+
# @return [Array<Versponse::Properties::Base>] properties one path segment below this one
|
|
145
|
+
def path_children(version)
|
|
146
|
+
[]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @param version [String] canonical version name
|
|
150
|
+
# @return [Versponse::Properties::Base] the property a path segment resolves to (self, or a reused copy)
|
|
151
|
+
def path_target(version)
|
|
152
|
+
self
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @return [Array<Array(Class, Symbol, String)>] Versponse classes this property renders, with their view and
|
|
156
|
+
# this property's key, for {Versponse::Internals#check_references!}
|
|
157
|
+
def reference_targets
|
|
158
|
+
[]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
protected
|
|
162
|
+
|
|
163
|
+
# @param name [Symbol, String] new name and output key
|
|
164
|
+
# @param map [String, nil] replacement map:
|
|
165
|
+
# @param block [Proc, nil] replacement value block
|
|
166
|
+
# @param condition [Versponse::Condition, nil] condition of the copy
|
|
167
|
+
# @param context_override [Versponse::ContextOverride::Merge, Versponse::ContextOverride::Call, nil] replaces the
|
|
168
|
+
# copy's context: when given
|
|
169
|
+
# @return [void]
|
|
170
|
+
# @raise [Versponse::DefinitionError] for a context_override on a property that doesn't take context:
|
|
171
|
+
def rename(name:, map:, block:, condition:, context_override: nil)
|
|
172
|
+
replace_source(map, block)
|
|
173
|
+
@name = name.to_sym
|
|
174
|
+
@key = name.to_s.freeze
|
|
175
|
+
@condition = condition
|
|
176
|
+
return if context_override.nil?
|
|
177
|
+
raise DefinitionError, "#{key}: context: only applies when the reused property is a reference, collection, object or one_of" unless self.class.context_option?
|
|
178
|
+
|
|
179
|
+
@context_override = context_override
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# A map: or block replaces the source; without either, keep the original name as the source
|
|
185
|
+
def replace_source(map, block)
|
|
186
|
+
if map || block
|
|
187
|
+
@map = map
|
|
188
|
+
@block = block
|
|
189
|
+
elsif @map.nil? && @block.nil?
|
|
190
|
+
@map = @key
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def check_options!(options)
|
|
195
|
+
raise DefinitionError, "#{key}: expandable: only works on reference, collection item: and one_of" if options.key?(:expandable)
|
|
196
|
+
raise DefinitionError, "#{key}: required is computed automatically, use if: or unless: for optional keys" if options.key?(:required)
|
|
197
|
+
raise DefinitionError, "#{key}: use either a block or map:, not both" if block && map
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def check_keywords!(options, label: key, written_keywords: self.class.written_keywords)
|
|
201
|
+
written = options.keys.find { |option| written_keywords.include?(Naming.camelize(option)) }
|
|
202
|
+
raise DefinitionError, "#{label}: #{written}: is written by #{dsl_name} itself" if written
|
|
203
|
+
|
|
204
|
+
unknown = options.keys.reject do |option|
|
|
205
|
+
keyword = Naming.camelize(option)
|
|
206
|
+
SCHEMA_KEYWORDS.include?(keyword) || keyword.start_with?("x-")
|
|
207
|
+
end
|
|
208
|
+
return if unknown.empty?
|
|
209
|
+
|
|
210
|
+
raise DefinitionError, "#{label}: unknown options #{unknown.map { |option| "#{option}:" }.join(", ")}#{suggestions(unknown)}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def dsl_name
|
|
214
|
+
self.class.name.split("::").last.gsub(/([a-z])([A-Z])/, '\\1_\\2').downcase.delete_prefix("item_")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def suggestions(unknown)
|
|
218
|
+
dictionary = DSL_OPTIONS + SCHEMA_KEYWORDS.map { |keyword| keyword.gsub(/[A-Z]/) { |letter| "_#{letter.downcase}" } }
|
|
219
|
+
checker = DidYouMean::SpellChecker.new(dictionary:)
|
|
220
|
+
found = unknown.filter_map { |option| checker.correct(option.to_s).first }
|
|
221
|
+
found.empty? ? " (use JSON Schema keywords in snake_case, or x- extensions)" : " (did you mean #{found.map { |option| "#{option}:" }.join(", ")}?)"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def extractor
|
|
225
|
+
Extractors.for(name:, map:, block:)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def check_on_error!
|
|
229
|
+
raise DefinitionError, "#{key}: on_error: must be :raise, :nullify, :omit or a callable taking (error, object, key, context)" unless ErrorPolicy.valid?(@on_error)
|
|
230
|
+
raise DefinitionError, "#{key}: on_error: :nullify needs nullable: true" if @on_error == :nullify && !nullable?
|
|
231
|
+
raise DefinitionError, "#{key}: on_error: :omit needs if: or unless:" if @on_error == :omit && !conditional?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# A static default stays in the options, so the schema writes it; a callable one only renders. default: nil
|
|
235
|
+
# means no default.
|
|
236
|
+
def parse_default!(options)
|
|
237
|
+
return unless options.key?(:default)
|
|
238
|
+
raise DefinitionError, "#{key}: default: only works on field, enum and array" unless self.class.default_option?
|
|
239
|
+
|
|
240
|
+
default = options.delete(:default)
|
|
241
|
+
return if default.nil?
|
|
242
|
+
|
|
243
|
+
@default = default.respond_to?(:call) ? Callable.normalize(default) : DeepFreeze.call(default)
|
|
244
|
+
options[:default] = @default unless default.respond_to?(:call)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Called by the subclasses once their schema is complete. Scalar schemas never read SchemaContext#version.
|
|
248
|
+
def check_default!
|
|
249
|
+
return if @default.nil? || @default.respond_to?(:call)
|
|
250
|
+
|
|
251
|
+
require "json_schemer"
|
|
252
|
+
schema = schema(SchemaContext.new(version: nil, validation: true))
|
|
253
|
+
return if JSONSchemer.schema(schema, meta_schema: Versponse.configuration.json_schema_uri, regexp_resolver: "ecma").valid?(@default)
|
|
254
|
+
|
|
255
|
+
raise DefinitionError, "#{key}: default: #{@default.inspect} doesn't match the property's schema"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# @return [#call, nil] (object, context) callable for nil values: default:, else the class's on_nil
|
|
259
|
+
def nil_fallback(policies)
|
|
260
|
+
return unless self.class.default_option?
|
|
261
|
+
return @default if @default.respond_to?(:call)
|
|
262
|
+
|
|
263
|
+
unless @default.nil?
|
|
264
|
+
value = @default
|
|
265
|
+
# A Hash or Array default is the caller's to change, so each render gets its own copy
|
|
266
|
+
return ->(_object, _context) { DeepCopy.call(value) } if DeepCopy.container?(value)
|
|
267
|
+
|
|
268
|
+
return ->(_object, _context) { value }
|
|
269
|
+
end
|
|
270
|
+
on_nil = policies.on_nil
|
|
271
|
+
key = @key
|
|
272
|
+
->(object, context) { on_nil.call(object, key, context) } if on_nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# A plain field (no block, condition, path map:, default: or on_nil) compiles to the specialized Steps::Read
|
|
276
|
+
def value_step(formatter: nil, policies: ClassPolicies::NONE)
|
|
277
|
+
fallback = nil_fallback(policies)
|
|
278
|
+
return (formatter ? Steps::ReadFormatted : Steps::Read).new(key:, name: map || name, formatter:) if fallback.nil? && plain_read?
|
|
279
|
+
|
|
280
|
+
reader = fallback ? Extractors::Fallback.new(extractor, fallback) : extractor
|
|
281
|
+
Steps::Value.new(key:, extractor: reader, condition:, formatter:)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def plain_read?
|
|
285
|
+
block.nil? && condition.nil? && (map.nil? || !(map == "." || map.include?("/") || map.empty?))
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def nullable_type(schema, context = nil)
|
|
289
|
+
return schema unless nullable?
|
|
290
|
+
|
|
291
|
+
# const allows one value, so null joins it in an enum
|
|
292
|
+
schema = schema.except("const").merge("enum" => [schema["const"], nil]) if schema.key?("const")
|
|
293
|
+
return schema.merge("nullable" => true) if nullable_keyword?(context)
|
|
294
|
+
|
|
295
|
+
schema.merge("type" => [*schema["type"], "null"])
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# OpenAPI 3.0 documents write null with the nullable keyword; validation schemas always use the 3.1 forms
|
|
299
|
+
def nullable_keyword?(context)
|
|
300
|
+
Versponse.configuration.nullable_keyword? && !context&.validation?
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# OpenAPI 3.0 documents (where nullable_keyword? holds) have no type arrays, so a union becomes an anyOf
|
|
304
|
+
def openapi30_union?(type, context)
|
|
305
|
+
type.is_a?(::Array) && nullable_keyword?(context)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def nullable_ref(schema, context)
|
|
309
|
+
return schema unless nullable?
|
|
310
|
+
|
|
311
|
+
{"anyOf" => [schema, null_schema(context)]}
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def nullable_choice(keyword, schemas, context)
|
|
315
|
+
return {keyword => schemas} unless nullable?
|
|
316
|
+
|
|
317
|
+
{keyword => [*schemas, null_schema(context)]}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def null_schema(context)
|
|
321
|
+
nullable_keyword?(context) ? NULL_SCHEMA_30 : NULL_SCHEMA
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Properties
|
|
5
|
+
# An array of objects: inline (block of properties) or of another class (item:)
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Collection < Base
|
|
9
|
+
# @return [Array<String>] keywords this property type writes itself
|
|
10
|
+
def self.written_keywords = %w[items 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 item [Class, nil] class implementing the reference protocol
|
|
18
|
+
# @param view [Symbol] view of item
|
|
19
|
+
# @param expandable [Boolean] render items collapsed unless expanded (item: must be a Versponse class)
|
|
20
|
+
# @param options [Hash] see {Base#initialize}
|
|
21
|
+
# @raise [Versponse::DefinitionError] when item does not implement the protocol, expandable: without a Versponse item:,
|
|
22
|
+
# or context: with a non-Versponse item:
|
|
23
|
+
def initialize(item: nil, view: :default, expandable: false, **options)
|
|
24
|
+
super(**options)
|
|
25
|
+
raise DefinitionError, "#{key}: item: must respond to to_ref and to_validation_schema" if item && !(item.respond_to?(:to_ref) && item.respond_to?(:to_validation_schema))
|
|
26
|
+
raise DefinitionError, "#{key}: expandable: needs item: with a Versponse class (mark an item one_of with expandable: instead)" if expandable && !References.versponse?(item)
|
|
27
|
+
|
|
28
|
+
@item = item
|
|
29
|
+
@view = view.to_sym
|
|
30
|
+
@expandable = expandable
|
|
31
|
+
raise DefinitionError, "#{key}: context: needs a Versponse class to render with it" if context_override && @item && !References.versponse?(@item)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] true when items render collapsed unless expanded
|
|
35
|
+
def expandable?
|
|
36
|
+
@expandable
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param property [Versponse::Properties::Base] item property of an inline collection
|
|
40
|
+
# @return [Versponse::Properties::Base] the property
|
|
41
|
+
# @raise [Versponse::DefinitionError] for a collection with item: or with an item one_of
|
|
42
|
+
def add(property)
|
|
43
|
+
raise DefinitionError, "#{key}: a collection with item: can not declare properties" if @item
|
|
44
|
+
raise DefinitionError, "#{key}: one_of must be the only declaration in a collection block" if @items_one_of
|
|
45
|
+
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param one_of [Versponse::Properties::ItemOneOf] unnamed one_of typing every item
|
|
50
|
+
# @return [Versponse::Properties::ItemOneOf] the one_of
|
|
51
|
+
# @raise [Versponse::DefinitionError] when the collection has item:, properties or another one_of
|
|
52
|
+
def items_one_of=(one_of)
|
|
53
|
+
raise DefinitionError, "#{key}: a collection with item: can not declare one_of" if @item
|
|
54
|
+
raise DefinitionError, "#{key}: one_of must be the only declaration in a collection block" if @items_one_of || !child_properties.empty?
|
|
55
|
+
|
|
56
|
+
@items_one_of = one_of
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param context [Versponse::SchemaContext] schema generation context
|
|
60
|
+
# @return [Hash] array schema
|
|
61
|
+
def schema(context)
|
|
62
|
+
nullable_type({"type" => "array", "items" => items_schema(context)}.merge(schema_options), context)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param version [String] canonical version name
|
|
66
|
+
# @param formatters [Hash{String => #call}] formatters by JSON Schema format, for the compiling class and version
|
|
67
|
+
# @param policies [Versponse::ClassPolicies] class-level on_nil and on_error of the compiling class
|
|
68
|
+
# @return [Versponse::Steps::Nested, Versponse::Steps::Value] compiled step; items of non-Versponse classes pass through
|
|
69
|
+
def step(version:, formatters:, policies: ClassPolicies::NONE)
|
|
70
|
+
plan = items_plan(version, formatters, policies)
|
|
71
|
+
return value_step if plan.nil?
|
|
72
|
+
|
|
73
|
+
collapse_if = (References.collapse_if(@item, version) if expandable?)
|
|
74
|
+
Steps::Nested.new(key:, extractor:, condition:, plan:, collapsed_plan: items_collapsed_plan(version), collapse_if:, collection: true, context_override:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param version [String] canonical version name
|
|
78
|
+
# @param prefix [Array<String>] output keys leading here
|
|
79
|
+
# @param stack [Array<Array(Class, Symbol)>] classes and views being walked
|
|
80
|
+
# @return [Array<Array(Array<String>, Array<Class>)>] entries of the items
|
|
81
|
+
def expandable_entries(version:, prefix:, stack:)
|
|
82
|
+
return @items_one_of.expandable_entries(version:, prefix:, stack:) if @items_one_of
|
|
83
|
+
return super unless @item
|
|
84
|
+
|
|
85
|
+
path = prefix + [key]
|
|
86
|
+
(expandable? ? [[path, [@item]]] : []) + References.nested_entries(@item, view: @view, version:, prefix: path, stack:)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Boolean] true when the items (or the item one_of) are expandable
|
|
90
|
+
def expandable_path?
|
|
91
|
+
expandable? || (@items_one_of&.expandable? || false)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param version [String] canonical version name
|
|
95
|
+
# @return [Array<Versponse::Properties::Base>] properties one segment below the items
|
|
96
|
+
def path_children(version)
|
|
97
|
+
return @items_one_of.path_children(version) if @items_one_of
|
|
98
|
+
return super unless @item
|
|
99
|
+
|
|
100
|
+
References.versponse?(@item) ? @item.versponse.resolve(version).properties_for(@view) : []
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Array<Array(Class, Symbol, String)>] the item class, the item one_of's variants, or the inline children's targets
|
|
104
|
+
def reference_targets
|
|
105
|
+
return @items_one_of.reference_targets if @items_one_of
|
|
106
|
+
return (References.versponse?(@item) ? [[@item, @view, key]] : []) if @item
|
|
107
|
+
|
|
108
|
+
super
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def items_schema(context)
|
|
114
|
+
return {"anyOf" => References.expandable_schemas(@item, view: @view, context:, key:)} if expandable?
|
|
115
|
+
return References.schema(@item, view: @view, property: nil, context:, key:) if @item
|
|
116
|
+
return @items_one_of.schema(context) if @items_one_of
|
|
117
|
+
|
|
118
|
+
inline_schema(context)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def items_collapsed_plan(version)
|
|
122
|
+
return References.collapsed_plan(@item, version) if expandable?
|
|
123
|
+
|
|
124
|
+
@items_one_of.collapsed_dispatcher(version) if @items_one_of&.expandable?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def items_plan(version, formatters, policies)
|
|
128
|
+
return @items_one_of.dispatcher(version) if @items_one_of
|
|
129
|
+
return inline_plan(version, formatters, policies) if @item.nil?
|
|
130
|
+
|
|
131
|
+
References.lazy_plan(@item, version:, view: @view, key:) if References.versponse?(@item)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Properties
|
|
5
|
+
# A constant value, always the same
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Const < Base
|
|
9
|
+
# @return [Array<String>] keywords this property type writes itself
|
|
10
|
+
def self.written_keywords = %w[const enum].freeze
|
|
11
|
+
|
|
12
|
+
# @param value [Object] constant value
|
|
13
|
+
# @param options [Hash] see {Base#initialize}
|
|
14
|
+
# @raise [Versponse::DefinitionError] with a block, map: or nullable: (a response constant never reads the object),
|
|
15
|
+
# or a value with no JSON type
|
|
16
|
+
def initialize(value:, **options)
|
|
17
|
+
super(**options)
|
|
18
|
+
raise DefinitionError, "#{key}: const never reads the object, so it takes no map: or block (use enum or field for computed values)" if block || map
|
|
19
|
+
raise DefinitionError, "#{key}: const always renders its value, so it can't be nullable (use enum with nil)" if nullable?
|
|
20
|
+
raise DefinitionError, "#{key}: const value #{value.inspect} has no JSON type#{" (Symbols render as Symbols, use Strings)" if value.is_a?(::Symbol)}" unless JSONValue.json?(value)
|
|
21
|
+
|
|
22
|
+
@constant = DeepFreeze.call(value)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Object] the frozen constant value
|
|
26
|
+
def value
|
|
27
|
+
@constant
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param _context [Versponse::SchemaContext] unused
|
|
31
|
+
# @return [Hash] const schema, or a one-value enum for OpenAPI 3.0
|
|
32
|
+
def schema(_context)
|
|
33
|
+
keyword = Versponse.configuration.const_keyword? ? {"const" => @constant} : {"enum" => [@constant]}
|
|
34
|
+
keyword.merge(schema_options)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param version [String] unused by constants
|
|
38
|
+
# @param formatters [Hash{String => #call}] unused by constants
|
|
39
|
+
# @param policies [Versponse::ClassPolicies] class-level on_nil and on_error of the compiling class
|
|
40
|
+
# @return [Versponse::Steps::Const] compiled step
|
|
41
|
+
def step(version:, formatters:, policies: ClassPolicies::NONE)
|
|
42
|
+
Steps::Const.new(key:, value: @constant, condition:)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Properties
|
|
5
|
+
# Child properties of an inline object or collection
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module Container
|
|
9
|
+
# @param property [Versponse::Properties::Base] child property
|
|
10
|
+
# @return [Versponse::Properties::Base] the property
|
|
11
|
+
def add(property)
|
|
12
|
+
(@properties ||= {})[property.key] = property
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param version [String] canonical version name
|
|
16
|
+
# @param prefix [Array<String>] output keys leading to this container
|
|
17
|
+
# @param stack [Array<Array(Class, Symbol)>] classes and views being walked
|
|
18
|
+
# @return [Array<Array(Array<String>, Array<Class>)>] entries of the child properties
|
|
19
|
+
def expandable_entries(version:, prefix:, stack:)
|
|
20
|
+
child_properties.flat_map { |property| property.expandable_entries(version:, prefix: prefix + [key], stack:) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param version [String] canonical version name
|
|
24
|
+
# @return [Array<Versponse::Properties::Base>] the child properties
|
|
25
|
+
def path_children(version)
|
|
26
|
+
child_properties
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<Array(Class, Symbol, String)>] the reference targets of the child properties
|
|
30
|
+
def reference_targets
|
|
31
|
+
child_properties.flat_map(&:reference_targets)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def child_properties
|
|
37
|
+
@properties&.values || []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inline_plan(version, formatters, policies)
|
|
41
|
+
properties = FieldOrder.call(child_properties)
|
|
42
|
+
Plan.new(label: key, steps: properties.map { |property| property.step(version:, formatters:, policies:) },
|
|
43
|
+
recoveries: ErrorPolicy.recoveries(properties, policies, version))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def inline_schema(context)
|
|
47
|
+
ObjectSchema.build(FieldOrder.call(child_properties), context)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Properties
|
|
5
|
+
# A value from a fixed list
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Enum < Base
|
|
9
|
+
# @return [Array<String>] keywords this property type writes itself
|
|
10
|
+
def self.written_keywords = %w[enum].freeze
|
|
11
|
+
|
|
12
|
+
# @return [Boolean] true: nil values can be replaced
|
|
13
|
+
def self.default_option? = true
|
|
14
|
+
|
|
15
|
+
# Ruby classes a value of each built-in type must be
|
|
16
|
+
VALUE_CLASSES = {
|
|
17
|
+
"string" => [::String],
|
|
18
|
+
"number" => [::Numeric],
|
|
19
|
+
"integer" => [::Integer],
|
|
20
|
+
"boolean" => [::TrueClass, ::FalseClass],
|
|
21
|
+
"object" => [::Hash]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# @param values [Array] allowed values, each of the type
|
|
25
|
+
# @param type [Symbol, String, Array<Symbol, String>, nil] a built-in type, a custom one (only its base type is used, its
|
|
26
|
+
# schema options would duplicate or contradict the values) or a union of built-in types; nil infers it from the values
|
|
27
|
+
# @param options [Hash] see {Base#initialize}
|
|
28
|
+
# @raise [Versponse::DefinitionError] when values is empty, type is unknown or can't be inferred, or a value isn't of the type
|
|
29
|
+
def initialize(values:, type: nil, **options)
|
|
30
|
+
super(**options)
|
|
31
|
+
raise DefinitionError, "#{key}: values must be a non-empty Array" unless values.is_a?(::Array) && !values.empty?
|
|
32
|
+
|
|
33
|
+
@values = values.dup.freeze
|
|
34
|
+
@type = type.nil? ? inferred_type : FieldType.resolve(type, key).first
|
|
35
|
+
check_values!
|
|
36
|
+
check_default!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param context [Versponse::SchemaContext, nil] schema generation context
|
|
40
|
+
# @return [Hash] JSON Schema of the enum; nullable adds nil to the values, and "null" to type (or nullable: true
|
|
41
|
+
# in OpenAPI 3.0 documents, where a type union is an anyOf of single types)
|
|
42
|
+
def schema(context)
|
|
43
|
+
options = {"enum" => nullable? ? @values | [nil] : @values}.merge(schema_options)
|
|
44
|
+
return FieldType.openapi30_union(@type, options, nullable: nullable?) if openapi30_union?(@type, context)
|
|
45
|
+
|
|
46
|
+
nullable_type({"type" => @type}.merge(options), context)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param version [String] unused by scalars
|
|
50
|
+
# @param formatters [Hash{String => #call}] unused by enums
|
|
51
|
+
# @param policies [Versponse::ClassPolicies] class-level on_nil and on_error of the compiling class
|
|
52
|
+
# @return [Versponse::Steps::Value] compiled step
|
|
53
|
+
def step(version:, formatters:, policies: ClassPolicies::NONE)
|
|
54
|
+
value_step(policies:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Like verquest's query coercion: one type per value class, number when integers and floats mix
|
|
60
|
+
def inferred_type
|
|
61
|
+
types = @values.compact.map do |value|
|
|
62
|
+
json_type(value) || raise(DefinitionError, "#{key}: can't infer the enum type from #{value.inspect}, pass type:#{" (Symbols render as Symbols, use Strings)" if value.is_a?(::Symbol)}")
|
|
63
|
+
end.uniq
|
|
64
|
+
raise DefinitionError, "#{key}: can't infer the enum type from nil alone, pass type:" if types.empty?
|
|
65
|
+
|
|
66
|
+
types.delete("integer") if types.include?("number")
|
|
67
|
+
(types.size == 1) ? types.first : FieldType.union(types, key)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def json_type(value)
|
|
71
|
+
case value
|
|
72
|
+
when ::String then "string"
|
|
73
|
+
when ::Integer then "integer"
|
|
74
|
+
when ::Numeric then "number"
|
|
75
|
+
when true, false then "boolean"
|
|
76
|
+
when ::Hash then "object"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def check_values!
|
|
81
|
+
types = Array(@type)
|
|
82
|
+
invalid = @values.find { |value| !(value.nil? && nullable?) && !matches?(value, types) }
|
|
83
|
+
return if invalid.nil? && (nullable? || !@values.include?(nil))
|
|
84
|
+
|
|
85
|
+
raise DefinitionError, "#{key}: enum value #{invalid.inspect} is not of type #{types.join(" or ")}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def matches?(value, types)
|
|
89
|
+
types.any? { |type| VALUE_CLASSES.fetch(type).any? { |klass| value.is_a?(klass) } }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|