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,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Builds the versions of one response class from its DSL calls ({Versponse::Base::DSL} delegates here).
5
+ # Holds the definition state (current scope, with_options defaults, last version), so none of it lives
6
+ # on the response class itself.
7
+ #
8
+ # @api private
9
+ class Builder
10
+ # @param klass [Class] response class
11
+ def initialize(klass)
12
+ @klass = klass
13
+ @current_scope = nil
14
+ @default_options = nil
15
+ @last_version = nil
16
+ end
17
+
18
+ # Sets the description of the class or the current version
19
+ #
20
+ # @param text [String] description
21
+ # @return [void]
22
+ def description(text)
23
+ class_or_version(:description).description = text
24
+ end
25
+
26
+ # Adds schema keywords to the class or the current version
27
+ #
28
+ # @param options [Hash] JSON Schema keywords in snake_case
29
+ # @return [void]
30
+ def schema_options(**options)
31
+ target = class_or_version(:schema_options)
32
+ keywords = Naming.camelize_keys(options)
33
+ check_schema_options!(keywords)
34
+ target.schema_options = target.schema_options.merge(keywords)
35
+ end
36
+
37
+ # Limits the versions the class serves; resolving a version outside the range raises
38
+ # VersionNotFoundError. Subclasses inherit the range; no arguments removes an inherited one.
39
+ #
40
+ # @param min [String, nil] first supported version (inclusive), defaults to the first declared version
41
+ # @param max [String, nil] last supported version (inclusive), defaults to no upper bound
42
+ # @return [void]
43
+ # @raise [Versponse::DefinitionError] when min sorts after max, inside a version, or after the first version
44
+ def supported_versions(min: nil, max: nil)
45
+ raise DefinitionError, "supported_versions must come before the first version" unless @klass.versponse.versions.empty?
46
+ raise DefinitionError, "supported_versions can only be set on the class" if @current_scope
47
+
48
+ range = (min.nil? && max.nil?) ? nil : SupportedVersions.new(min:, max:)
49
+ @klass.versponse.supported_version_range = range
50
+ Registry.add_range(range) if range
51
+ Versponse.reset_caches!
52
+ end
53
+
54
+ # Sets the fraction digits of date-time values, from a version on when from: is given
55
+ #
56
+ # @param digits [Integer] fraction digits from 0 (whole seconds) to 9
57
+ # @param from [String, nil] first version it applies to
58
+ # @return [void]
59
+ # @raise [Versponse::DefinitionError] inside a version, after the first version, for invalid digits,
60
+ # or when the class already sets the precision from that version
61
+ def time_precision(digits, from: nil)
62
+ raise DefinitionError, "time_precision can only be set on the class" if @current_scope
63
+ raise DefinitionError, "time_precision must come before the first version" unless @klass.versponse.versions.empty?
64
+ raise DefinitionError, "time_precision must be an Integer from 0 to 9, got #{digits.inspect}" unless digits.is_a?(Integer) && digits.between?(0, 9)
65
+
66
+ from = from&.to_s
67
+ @klass.versponse.add_time_precision(digits, from:)
68
+ Registry.add_version_name(from) if from
69
+ Versponse.reset_caches!
70
+ end
71
+
72
+ # Replaces nil values of the class's field, enum and array properties without default:
73
+ #
74
+ # @param callable [#call, nil] called with (object, key, context); nil removes an inherited one
75
+ # @return [void]
76
+ # @raise [Versponse::DefinitionError] inside a version, after the first version, or for a value that can't take
77
+ # (object, key, context)
78
+ def on_nil(callable)
79
+ class_setting!(:on_nil)
80
+ raise DefinitionError, "on_nil must be a callable taking (object, key, context), or nil to remove an inherited one" unless callable.nil? || Callable.accepts?(callable, 3)
81
+
82
+ @klass.versponse.on_nil = callable
83
+ Versponse.reset_caches!
84
+ end
85
+
86
+ # Policy for the class's failing properties without their own on_error:
87
+ #
88
+ # @param policy [Symbol, #call, nil] :raise, :nullify, :omit, a handler, or nil to remove an inherited one
89
+ # @return [void]
90
+ # @raise [Versponse::DefinitionError] inside a version, after the first version, or for an unknown policy
91
+ def on_error(policy)
92
+ class_setting!(:on_error)
93
+ raise DefinitionError, "on_error must be :raise, :nullify, :omit or a callable taking (error, object, key, context)" unless policy.nil? || ErrorPolicy.valid?(policy)
94
+
95
+ @klass.versponse.on_error = policy
96
+ Versponse.reset_caches!
97
+ end
98
+
99
+ # Declares a version
100
+ #
101
+ # @param name [String] version name, compared as a String
102
+ # @param inherit [Boolean, String, Symbol] true copies the previously declared version, a name copies the
103
+ # closest version at or before it
104
+ # @param exclude_properties [Array<Symbol>] inherited properties to leave out
105
+ # @return [void]
106
+ # @raise [Versponse::DefinitionError] when nested in another version
107
+ # @raise [Versponse::DefinitionError] when inherit: names a version before every declared one
108
+ def version(name, inherit: true, exclude_properties: [], &block)
109
+ raise DefinitionError, "version blocks can not be nested" if @current_scope
110
+
111
+ # As in verquest, a version block starts without with_options defaults
112
+ outer_defaults = @default_options
113
+ @default_options = nil
114
+ begin
115
+ definition = Version.new(name: name.to_s, owner: @klass.name || "AnonymousResponse")
116
+ source = version_source(inherit)
117
+ raise DefinitionError, "#{definition.label}: exclude_properties: needs a version to inherit from" if source.nil? && exclude_properties.any?
118
+
119
+ definition.copy_from(source, exclude_properties:) if source
120
+ @current_scope = definition
121
+ @klass.instance_exec(&block) if block
122
+ finish_version(definition)
123
+ ensure
124
+ @current_scope = nil
125
+ @default_options = outer_defaults
126
+ end
127
+ end
128
+
129
+ # Computes the value of the enclosing inline object, inline collection or one_of
130
+ #
131
+ # @yieldparam object [Object] serialized object
132
+ # @yieldparam context [Hash] serialize context
133
+ # @return [void]
134
+ def source(&block)
135
+ raise DefinitionError, "source can only be used inside object, collection or one_of" unless @current_scope.is_a?(Properties::Base)
136
+
137
+ @current_scope.source = block
138
+ end
139
+
140
+ # Declares a scalar field
141
+ #
142
+ # @param name [Symbol] output key
143
+ # @param type [Symbol, Array<Symbol>] string, number, integer, boolean, object, a custom type, or a union like [:integer, :string]
144
+ # @param options [Hash] map:, nullable:, if:, unless:, JSON Schema keywords
145
+ # @yieldparam object [Object] serialized object
146
+ # @yieldparam context [Hash] serialize context
147
+ # @return [void]
148
+ def field(name, type: nil, **options, &block)
149
+ add_property(Properties::Field, name:, type: type || default_options[:type], block:, **options)
150
+ end
151
+
152
+ # Declares a field of both the full shape and the collapsed shape, rendered for expandable associations that aren't expanded
153
+ #
154
+ # @param name [Symbol] output key
155
+ # @param type [Symbol, nil] field type (with_options type:, else :string)
156
+ # @param collapse_if [Proc, #call, nil] (not taken from with_options) called with (object) or (object, context); when it returns false the
157
+ # association renders in full instead (a collection item: when it is false for any item), and the full shape leaves this field out.
158
+ # One per version.
159
+ # @param options [Hash] map:, nullable:, description: and JSON Schema keywords (no if:/unless:); with_options defaults apply
160
+ # @return [void]
161
+ # @raise [Versponse::DefinitionError] outside a version, with if:/unless:, or with a collapse_if: that doesn't respond to call
162
+ def identifier(name, type: nil, collapse_if: nil, **options, &block)
163
+ raise DefinitionError, "identifier must be declared inside a version" unless @current_scope.is_a?(Version)
164
+ raise DefinitionError, "identifier #{name} takes no if: or unless:" if options.key?(:if) || options.key?(:unless)
165
+ raise DefinitionError, "identifier #{name}: collapse_if: must respond to call" unless collapse_if.nil? || collapse_if.respond_to?(:call)
166
+
167
+ defaults = default_options.except(:type, :if, :unless, :collapse_if, :view, :discriminator, :context)
168
+ type ||= default_options[:type] || :string
169
+ identifier = Properties::Field.new(**defaults, name:, type:, block:, **options)
170
+ property = collapse_if ? Properties::Field.new(**defaults, name:, type:, block:, if: collapse_if, **options) : identifier
171
+ @current_scope.add_identifier(identifier, property_in_full: property, collapse_if: Callable.normalize(collapse_if))
172
+ end
173
+
174
+ # Declares a value from a fixed list
175
+ #
176
+ # @param name [Symbol] output key
177
+ # @param type [Symbol, Array<Symbol>, nil] type of the values, as for {#field} (with_options type:); inferred from the values when nil
178
+ # @param values [Array] allowed values, each of the type
179
+ # @param options [Hash] see {#field}
180
+ # @return [void]
181
+ # @raise [Versponse::DefinitionError] when the type can't be inferred, or a value isn't of the type
182
+ def enum(name, values:, type: nil, **options, &block)
183
+ add_property(Properties::Enum, name:, values:, type: type || default_options[:type], block:, **options)
184
+ end
185
+
186
+ # Declares a constant
187
+ #
188
+ # @param name [Symbol] output key
189
+ # @param value [Object] constant value
190
+ # @param options [Hash] if:, unless:, JSON Schema keywords
191
+ # @return [void]
192
+ def const(name, value:, **options, &block)
193
+ add_property(Properties::Const, name:, value:, block:, ignore_defaults: %i[map nullable], **options)
194
+ end
195
+
196
+ # Declares an array of scalars
197
+ #
198
+ # @param name [Symbol] output key
199
+ # @param type [Symbol, Array<Symbol>] item type, or a union like [:integer, :string]
200
+ # @param item_schema_options [Hash] JSON Schema keywords of each item
201
+ # @param options [Hash] see {#field}
202
+ # @return [void]
203
+ def array(name, type: nil, item_schema_options: {}, **options, &block)
204
+ add_property(Properties::Array, name:, type: type || default_options[:type], item_schema_options:, block:, **options)
205
+ end
206
+
207
+ # Declares an inline object; the block defines its properties
208
+ #
209
+ # @param name [Symbol] output key
210
+ # @param options [Hash] map: (use "." for the same object), nullable:, if:, unless:, JSON Schema keywords
211
+ # @return [void]
212
+ def object(name, **options, &block)
213
+ raise DefinitionError, "object #{name} needs a block defining its properties" unless block
214
+
215
+ nested(add_property(Properties::Object, name:, **options), &block)
216
+ end
217
+
218
+ # Declares an array of objects, either of another response class (item:) or inline (block)
219
+ #
220
+ # With item:, a block computes the value. Without item:, the block defines the item properties.
221
+ #
222
+ # @param name [Symbol] output key
223
+ # @param item [Class, nil] class implementing the reference protocol
224
+ # @param view [Symbol, nil] view of item (with_options view:, else :default)
225
+ # @param options [Hash] see {#field}
226
+ # @return [void]
227
+ def collection(name, item: nil, view: nil, **options, &block)
228
+ view ||= default_options[:view] || :default
229
+ return add_property(Properties::Collection, name:, item:, view:, block:, **options) if item
230
+ raise DefinitionError, "collection #{name} needs item: or a block defining its properties" unless block
231
+
232
+ nested(add_property(Properties::Collection, name:, **options), &block)
233
+ end
234
+
235
+ # Declares a nested response class, or a variant inside one_of
236
+ #
237
+ # @param name [Symbol, nil] output key, omitted inside one_of
238
+ # @param from [Class] class implementing the reference protocol
239
+ # @param view [Symbol, nil] view of from (with_options view:, else :default)
240
+ # @param property [Symbol, nil] one property of a non-Versponse class's schema
241
+ # @param match [Class, Proc, nil] one_of matcher, compared with ===
242
+ # @param options [Hash] see {#field}
243
+ # @return [void]
244
+ def reference(name = nil, from:, view: nil, property: nil, match: nil, **options, &block)
245
+ view ||= default_options[:view] || :default
246
+ if @current_scope.is_a?(Properties::OneOf)
247
+ return one_of_variant(name, from:, view:, match:, context: options[:context], extra: options.except(:context).any? || block || property)
248
+ end
249
+ raise DefinitionError, "reference needs a name outside one_of" if name.nil?
250
+ raise DefinitionError, "#{name}: match: is only used for one_of variants" if match
251
+
252
+ ignore_defaults = (property && References.versponse?(from)) ? %i[nullable context] : []
253
+ add_property(Properties::Reference, name:, from:, view:, property:, block:, ignore_defaults:, **options)
254
+ end
255
+
256
+ # Declares a polymorphic value; the block declares variants with reference from:, match:.
257
+ # Without a name, inside a collection block, it types each item of the collection.
258
+ #
259
+ # @param positional_name [Symbol, nil] output key; nil for the item one_of of a collection
260
+ # @param name [Symbol, nil] the output key as a keyword, as verquest writes it
261
+ # @param discriminator [String, Symbol, nil] OpenAPI discriminator property
262
+ # @param options [Hash] see {#field}; an item one_of takes only nullable: and schema keywords
263
+ # @return [void]
264
+ # @raise [Versponse::DefinitionError] when the name is given both ways
265
+ def one_of(positional_name = nil, name: nil, discriminator: nil, **options, &block)
266
+ raise DefinitionError, "one_of #{positional_name}: give the name once, not also as name:" if positional_name && name
267
+
268
+ name ||= positional_name
269
+ discriminator ||= default_options[:discriminator]
270
+ raise DefinitionError, "#{"one_of #{name}".strip} needs a block declaring its variants" unless block
271
+ return item_one_of(discriminator:, **options, &block) if name.nil?
272
+
273
+ property = add_property(Properties::OneOf, name:, discriminator:, **options)
274
+ nested(property, &block)
275
+ raise DefinitionError, "one_of #{name} needs at least one variant" unless property.variants?
276
+ end
277
+
278
+ # Declares a view inside a version
279
+ #
280
+ # @param name [Symbol] view name
281
+ # @return [void]
282
+ def view(name, &block)
283
+ raise DefinitionError, "view must be declared inside a version" unless @current_scope.is_a?(Version)
284
+
285
+ definition = View.new(name:)
286
+ @current_scope.add_view(definition)
287
+ @view_version = @current_scope
288
+ nested(definition, &block) if block
289
+ ensure
290
+ @view_version = nil
291
+ end
292
+
293
+ # Applies another view's operations inside a view
294
+ #
295
+ # @param name [Symbol] included view
296
+ # @return [void]
297
+ def include_view(name)
298
+ raise DefinitionError, "include_view can only be used inside a view" unless @current_scope.is_a?(View)
299
+
300
+ @current_scope.include_view(name)
301
+ end
302
+
303
+ # Removes properties from the current version or view
304
+ #
305
+ # @param names [Array<Symbol>] property names
306
+ # @return [void]
307
+ def exclude_properties(*names)
308
+ case @current_scope
309
+ when Version then names.each { |name| @current_scope.remove(name) }
310
+ when View then @current_scope.exclude(names)
311
+ else raise DefinitionError, "exclude_properties can only be used inside a version or a view"
312
+ end
313
+ end
314
+
315
+ # Applies options to every property declared in the block. A nested with_options replaces the outer
316
+ # options, and map: is ignored, as in verquest.
317
+ #
318
+ # @param options [Hash] shared options
319
+ # @return [void]
320
+ def with_options(**options)
321
+ previous = @default_options
322
+ # As in verquest: a nested block replaces the outer options, and map: never applies to a group
323
+ @default_options = options.except(:map)
324
+ yield
325
+ ensure
326
+ @default_options = previous
327
+ end
328
+
329
+ private
330
+
331
+ def class_setting!(name)
332
+ raise DefinitionError, "#{name} can only be set on the class" if @current_scope
333
+ raise DefinitionError, "#{name} must come before the first version" unless @klass.versponse.versions.empty?
334
+ end
335
+
336
+ def default_options
337
+ @default_options || {}
338
+ end
339
+
340
+ def class_or_version(keyword)
341
+ target = @current_scope || @klass.versponse.versions
342
+ raise DefinitionError, "#{keyword} on the class must come before the first version" if target.is_a?(Versions) && !@klass.versponse.versions.empty?
343
+ return target if target.is_a?(Versions) || target.is_a?(Version)
344
+
345
+ raise DefinitionError, "#{keyword} can only be set on the class or a version"
346
+ end
347
+
348
+ def version_source(inherit)
349
+ return inherited_version(inherit.to_s) if inherit.is_a?(String) || inherit.is_a?(Symbol)
350
+
351
+ @last_version if inherit
352
+ end
353
+
354
+ # Resolves like a request (and like verquest's inherit:), ignoring supported_versions
355
+ def inherited_version(name)
356
+ versions = @klass.versponse.versions
357
+ resolved = Versponse.configuration.version_resolver.call(name, versions.sorted_names)
358
+ return versions.fetch(resolved) if resolved
359
+
360
+ raise DefinitionError, "inherit: no version of #{@klass.name || "AnonymousResponse"} at or before #{name} (declared: #{versions.sorted_names.join(", ")})"
361
+ end
362
+
363
+ def finish_version(definition)
364
+ @klass.versponse.versions.add(definition.finalize!)
365
+ @last_version = definition
366
+ # Any class may embed this one in a plan or validation schema, so a reopened class (forward
367
+ # declarations, verquest references) clears every cache; this only happens at definition time
368
+ Versponse.reset_caches!
369
+ end
370
+
371
+ def one_of_variant(name, from:, view:, match:, context:, extra:)
372
+ raise DefinitionError, "one_of variants take no name, block or options besides view:, match: and context: (use source inside one_of)" if name || extra
373
+
374
+ @current_scope.add_variant(from:, match:, view:, context:)
375
+ end
376
+
377
+ def item_one_of(discriminator:, **options, &block)
378
+ collection = @current_scope
379
+ raise DefinitionError, "one_of without a name is only allowed inside a collection block" unless collection.is_a?(Properties::Collection)
380
+ if options.key?(:map) || options.key?(:if) || options.key?(:unless) || options.key?(:context)
381
+ raise DefinitionError, "#{collection.key}: the item one_of takes no map:, if:, unless: or context: (give context: to the collection)"
382
+ end
383
+
384
+ property = Properties::ItemOneOf.new(name: collection.key, discriminator:, **default_options.slice(:nullable), **options)
385
+ collection.items_one_of = property
386
+ nested(property, &block)
387
+ raise DefinitionError, "#{collection.key}: one_of needs at least one variant" unless property.variants?
388
+ end
389
+
390
+ def add_property(klass, ignore_defaults: [], **options)
391
+ scope = @current_scope || raise(DefinitionError, "Properties must be declared inside a version block")
392
+ defaults = default_options.except(:type, :collapse_if, :view, :discriminator, *ignore_defaults)
393
+ target = options[:from] || options[:item]
394
+ takes_context = klass.context_option? && (target.nil? || References.versponse?(target))
395
+ defaults = defaults.except(:context) unless takes_context
396
+ defaults = defaults.except(:default) unless klass.default_option?
397
+ property = klass.new(**defaults, **options)
398
+ if scope.is_a?(View) && @view_version&.identifiers&.key?(property.key)
399
+ raise DefinitionError, "#{@view_version.label}: #{property.key} is declared by identifier"
400
+ end
401
+
402
+ scope.add(property)
403
+ property
404
+ end
405
+
406
+ # Class- and version-level keywords: the property keywords and x- extensions, but not the object
407
+ # keywords the object schema writes itself
408
+ def check_schema_options!(keywords)
409
+ unknown = keywords.keys.reject do |keyword|
410
+ keyword.start_with?("x-") || (Properties::Base::SCHEMA_KEYWORDS.include?(keyword) && !%w[properties required].include?(keyword))
411
+ end
412
+ raise DefinitionError, "schema_options: unknown or reserved keywords #{unknown.join(", ")}" if unknown.any?
413
+ end
414
+
415
+ def nested(scope)
416
+ previous = @current_scope
417
+ @current_scope = scope
418
+ yield
419
+ ensure
420
+ @current_scope = previous
421
+ end
422
+ end
423
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Thread-safe compute-once cache. Reads take no lock: the store is a frozen Hash replaced on write.
5
+ #
6
+ # A block passed to {#fetch} must not fetch from the same cache (the lock is not reentrant).
7
+ # With a limit, keys past it are computed on every fetch instead of stored, for keys that come from requests.
8
+ #
9
+ # @api private
10
+ class Cache
11
+ # @param limit [Integer, nil] most keys stored, nil for no limit
12
+ # @return [Cache] an empty cache
13
+ def initialize(limit: nil)
14
+ @store = {}.freeze
15
+ @mutex = Mutex.new
16
+ @limit = limit
17
+ end
18
+
19
+ # @param key [Object] cache key
20
+ # @yieldreturn [Object] value computed when the key is missing
21
+ # @return [Object] the cached value
22
+ def fetch(key)
23
+ @store.fetch(key) do
24
+ return yield if @limit && @store.size >= @limit
25
+
26
+ @mutex.synchronize do
27
+ @store.fetch(key) do
28
+ value = yield
29
+ @store = @store.merge(key => value).freeze
30
+ value
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ # @param key [Object] cache key
37
+ # @return [Object, nil] the cached value, nil when missing (computes nothing)
38
+ def [](key)
39
+ @store[key]
40
+ end
41
+
42
+ # @return [void]
43
+ def clear
44
+ @mutex.synchronize { @store = {}.freeze }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Lets one-argument lambdas and methods be used where Versponse passes (object, context)
5
+ #
6
+ # @api private
7
+ module Callable
8
+ # Symbol procs (&:name) and C methods have unnamed parameters, and take the object only
9
+ # even when their rest parameter would accept the context too
10
+ #
11
+ # @param callable [Proc, Method, #call, nil] block, lambda, method or other callable
12
+ # @return [#call, nil] a callable that accepts (object, context)
13
+ # @raise [Versponse::DefinitionError] for a value that isn't callable, or a lambda or method
14
+ # that takes neither 1 nor 2 arguments
15
+ def self.normalize(callable)
16
+ return if callable.nil?
17
+ raise DefinitionError, "expected a callable (a Proc, lambda or Method), got #{callable.inspect}" unless callable.respond_to?(:call)
18
+ return callable if callable.is_a?(Proc) && !callable.lambda?
19
+
20
+ params = (callable.respond_to?(:parameters) ? callable : call_method(callable)).parameters
21
+ raise DefinitionError, "callables cannot take required keyword arguments" if params.any? { |type, _| type == :keyreq }
22
+
23
+ required = params.count { |type, _| type == :req }
24
+ max = (params.any? { |type, _| type == :rest }) ? Float::INFINITY : required + params.count { |type, _| type == :opt }
25
+ one = 1.between?(required, max)
26
+ return callable if 2.between?(required, max) && !(one && params.any? { |_, name| name.nil? })
27
+ return ->(object, _context) { callable.call(object) } if one
28
+
29
+ raise DefinitionError, "callables take (object) or (object, context)"
30
+ end
31
+
32
+ # Lambdas, Methods and other objects' call methods check their arity, so one that can't take the arguments fails
33
+ # when it's declared, not on a render; procs are taken as they are
34
+ #
35
+ # @param callable [Object] value to check
36
+ # @param count [Integer] positional arguments Versponse passes
37
+ # @return [Boolean] true when callable responds to call and, unless it is a proc, takes count positional
38
+ # arguments and no required keywords
39
+ def self.accepts?(callable, count)
40
+ return false unless callable.respond_to?(:call)
41
+ return true if callable.is_a?(Proc) && !callable.lambda?
42
+
43
+ types = parameters(callable)&.map(&:first)
44
+ return true if types.nil?
45
+
46
+ required = types.count(:req)
47
+ max = types.include?(:rest) ? Float::INFINITY : required + types.count(:opt)
48
+ count.between?(required, max) && !types.include?(:keyreq)
49
+ end
50
+
51
+ # @param callable [#call] lambda, Method or object
52
+ # @return [Array<Array(Symbol, Symbol)>, nil] its call's parameters, nil when respond_to? answers for a call
53
+ # method Ruby can't find
54
+ def self.parameters(callable)
55
+ (callable.is_a?(Proc) || callable.is_a?(Method)) ? callable.parameters : call_method(callable).parameters
56
+ rescue NameError
57
+ nil
58
+ end
59
+ private_class_method :parameters
60
+
61
+ # Kernel#method, so an object with its own #method (a Struct member, a request) still answers
62
+ #
63
+ # @param callable [#call] object
64
+ # @return [Method] its call method
65
+ def self.call_method(callable)
66
+ KERNEL_METHOD.bind_call(callable, :call)
67
+ end
68
+ private_class_method :call_method
69
+
70
+ # Kernel#method, unbound
71
+ KERNEL_METHOD = Kernel.instance_method(:method)
72
+ private_constant :KERNEL_METHOD
73
+ end
74
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Class-level settings a property needs when its step is compiled: the response class (for reports), its
5
+ # on_nil and its on_error
6
+ #
7
+ # @api private
8
+ ClassPolicies = Data.define(:response, :on_nil, :on_error)
9
+
10
+ # Reopened for the constant (RuboCop's Style/DataInheritance rules out subclassing Data.define)
11
+ class ClassPolicies
12
+ # Settings of no class: steps compiled outside a response class
13
+ NONE = new(response: nil, on_nil: nil, on_error: nil)
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Collapsed one_of variant whose class declares collapse_if: renders the full plan when the condition is false
5
+ #
6
+ # @api private
7
+ class CollapseIfPlan
8
+ # @param condition [#call] collapse_if, called with (object, context)
9
+ # @param collapsed [#call] the variant's collapsed plan
10
+ # @param full [#call] the variant's full plan
11
+ def initialize(condition, collapsed:, full:)
12
+ @condition = condition
13
+ @collapsed = collapsed
14
+ @full = full
15
+ freeze
16
+ end
17
+
18
+ # @param object [Object] source
19
+ # @param context [Hash] serialize context
20
+ # @param expansion [Versponse::Expansion] expand tree node
21
+ # @return [Hash] the collapsed form, or the full form when the condition is false
22
+ def call(object, context, expansion = Expansion::NONE)
23
+ (@condition.call(object, context) ? @collapsed : @full).call(object, context, expansion)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # What renders as a collection (anything with to_ary, such as an Array or an ActiveRecord relation,
5
+ # and Sets), and rendering its items with the index in extraction errors
6
+ #
7
+ # @api private
8
+ module Collections
9
+ # @param value [Object] source
10
+ # @return [Boolean] true when the value renders as a collection
11
+ def self.collection?(value)
12
+ value.respond_to?(:to_ary) || value.is_a?(::Set)
13
+ end
14
+
15
+ # @param value [#to_ary, Set] collection
16
+ # @return [Array] its items
17
+ # @raise [KeyError] for a value that isn't a collection (the plan reports it with the key path)
18
+ def self.items(value)
19
+ return value.to_ary if value.respond_to?(:to_ary)
20
+ return value.to_a if value.is_a?(::Set)
21
+
22
+ raise KeyError, "expected a collection, got #{value.class}"
23
+ end
24
+
25
+ # @param items [Array] items to render
26
+ # @yieldparam item [Object] one item
27
+ # @return [Array] the rendered items
28
+ # @raise [Versponse::ExtractionError] with the item's index in its keys when an item fails; an on_recovered_error
29
+ # hook's error propagates as raised
30
+ def self.map(items)
31
+ index = 0
32
+ items.map do |item|
33
+ rendered = yield(item)
34
+ index += 1
35
+ rendered
36
+ end
37
+ rescue ExtractionError => e
38
+ raise if ErrorPolicy.unrecoverable?(e)
39
+
40
+ raise ExtractionError.new(label: e.label, keys: [index.to_s, *e.keys], detail: e.detail)
41
+ rescue NoMethodError, KeyError => e
42
+ # An on_recovered_error hook's error propagates as raised; otherwise this is a one_of item matching no
43
+ # variant, which no plan wrapped (the parent plan adds its label)
44
+ raise if ErrorPolicy.unrecoverable?(e)
45
+
46
+ raise ExtractionError.new(label: "", keys: [index.to_s], detail: e.message)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Builds the components/schemas hash of an OpenAPI document for one version
5
+ #
6
+ # @api private
7
+ module Components
8
+ # @param version [String] requested version
9
+ # @param classes [Array<Class>] response classes
10
+ # @return [Hash{String => Hash}] schemas keyed by component name, one per class and view
11
+ # @raise [Versponse::DefinitionError] when two components share a name
12
+ def self.call(version:, classes:)
13
+ classes.each_with_object({}) do |klass, components|
14
+ definition = resolved(klass, version)
15
+ next unless definition
16
+
17
+ definition.view_names.each do |view|
18
+ name = klass.component_name(view:)
19
+ raise DefinitionError, "Duplicate component name #{name} (#{klass.name || klass.inspect})" if components.key?(name)
20
+
21
+ components[name] = klass.to_schema(version:, view:)
22
+ end
23
+ end
24
+ end
25
+
26
+ # @param klass [Class] response class
27
+ # @param version [String] requested version
28
+ # @return [Versponse::Version, nil] the resolved version, nil when the class does not cover it
29
+ def self.resolved(klass, version)
30
+ return if klass.versponse.versions.empty?
31
+
32
+ klass.versponse.resolve(version)
33
+ rescue VersionNotFoundError
34
+ nil
35
+ end
36
+ end
37
+ end