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