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