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
data/Rakefile CHANGED
@@ -1,12 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- require "minitest/test_task"
4
+ require "rake/testtask"
5
5
 
6
- Minitest::TestTask.create
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.warning = true
11
+ end
7
12
 
8
13
  require "rubocop/rake_task"
9
14
 
10
15
  RuboCop::RakeTask.new
11
16
 
12
- task default: %i[test rubocop]
17
+ desc "Check YARD documentation coverage (must be 100%)"
18
+ task :yard do
19
+ require "yard"
20
+
21
+ stats = YARD::CLI::Stats.new
22
+ stats.run("--list-undoc")
23
+ undocumented = stats.instance_variable_get(:@undoc_list) || []
24
+
25
+ abort "\nYARD documentation check failed: #{undocumented.size} undocumented objects found." unless undocumented.empty?
26
+
27
+ puts "\nYARD documentation: 100% documented"
28
+ end
29
+
30
+ desc "Benchmark versponse against alba and blueprinter"
31
+ task :bench do
32
+ ruby "-Ilib", "bench/serializers.rb"
33
+ end
34
+
35
+ task default: %i[test rubocop yard]
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Public class methods of {Versponse::Base}
5
+ module Base::ClassMethods
6
+ # Context used when serialize gets none (avoids two allocations per call)
7
+ EMPTY_CONTEXT = {}.freeze
8
+
9
+ # Versions, resolution and caches of the class
10
+ #
11
+ # @return [Versponse::Internals] per-class internals
12
+ # @api private
13
+ def versponse
14
+ @versponse ||= Internals.new(self)
15
+ end
16
+
17
+ # Renders an object, or each item of an Array-like collection, to a Hash with String keys
18
+ #
19
+ # @param object [Object, Hash, #to_ary, Set] source
20
+ # @param version [String, nil] requested version, config.current_version when nil
21
+ # @param view [Symbol] view name
22
+ # @param context [Hash, nil] passed to blocks and conditions; nil for none
23
+ # @param root [String, Symbol, nil] wraps the result under this key
24
+ # @param meta [Hash, nil] added under "meta" with String keys (requires root:)
25
+ # @return [Hash, Array<Hash>] rendered output
26
+ # @param expand [Array<String, Symbol>, nil] dotted paths of expandable associations to expand, or "*"
27
+ # @param fields [Array<String, Symbol>, nil] dotted paths of the fields to render (identifiers and consts always
28
+ # render), "*" or nil for all
29
+ # @raise [ArgumentError] without a version or with meta: but no root:
30
+ # @raise [Versponse::InvalidExpandError] for unknown expand paths (config.unknown_expand = :raise)
31
+ # @raise [Versponse::InvalidFieldsError] for unknown paths or paths below unexpanded associations
32
+ # (config.unknown_fields = :raise)
33
+ # @raise [Versponse::VersionNotFoundError] when no declared version matches, or supported_versions leaves it out
34
+ # @raise [Versponse::ViewNotFoundError] for an unknown or nil view
35
+ # @raise [Versponse::ExtractionError] when reading a value fails
36
+ def serialize(object, version: nil, view: :default, context: EMPTY_CONTEXT, root: nil, meta: nil, expand: nil, fields: nil)
37
+ raise ArgumentError, "meta: requires root:" if meta && root.nil?
38
+ raise ViewNotFoundError, "view: can't be nil; the default view is :default" if view.nil?
39
+
40
+ context ||= EMPTY_CONTEXT
41
+ internals = versponse
42
+ internals.requesting(version) do |canonical|
43
+ expansion = internals.expansion(expand, version: canonical, view:)
44
+ selection = internals.selection(fields, version: canonical, view:, expansion:) unless fields.nil?
45
+ context = context.frozen? ? context : context.dup.freeze
46
+ validate = Versponse.configuration.validate_output && OutputValidator.enabled?(self)
47
+ if selection && !selection.all?
48
+ # Validating plans prove the full shape, so a selection renders with the plain plan
49
+ data = render(object, internals.plan(version: canonical, view:), context, Selection::Scope.new(expansion, selection))
50
+ OutputValidator.call(self, data, version: canonical, view:, selection:) if validate
51
+ # A validating plan checks while rendering; the output goes through OutputValidator only when a check failed
52
+ elsif validate && (inline = internals.validating_plan(version: canonical, view:))
53
+ data = nil
54
+ failed = InlineValidation.run { data = render(object, inline, context, expansion) }
55
+ OutputValidator.call(self, data, version: canonical, view:) if failed
56
+ else
57
+ # The plain plan is looked up only here and for a selection, so a validated render skips it; render is
58
+ # written out: the extra call costs about 2% of a small render in the interpreter
59
+ plan = internals.plan(version: canonical, view:)
60
+ data = Collections.collection?(object) ? Collections.map(Collections.items(object)) { |item| plan.call(item, context, expansion) } : plan.call(object, context, expansion)
61
+ OutputValidator.call(self, data, version: canonical, view:) if validate
62
+ end
63
+ Wrapper.wrap(data, root:, meta:)
64
+ end
65
+ end
66
+
67
+ # @param object [Object] source
68
+ # @param options [Hash] see {#serialize}
69
+ # @return [String] JSON produced by config.json_generator
70
+ def serialize_json(object, **options)
71
+ Versponse.configuration.json_generator.call(serialize(object, **options))
72
+ end
73
+
74
+ # OpenAPI schema with $refs to other components, or a root/meta wrapper schema
75
+ #
76
+ # @param version [String, nil] requested version, config.current_version when nil
77
+ # @param view [Symbol] view name
78
+ # @param root [String, Symbol, nil] builds a wrapper schema with the response under this key
79
+ # @param meta [Class, nil] class whose $ref describes "meta" (requires root:)
80
+ # @param collection [Boolean] the root key holds an array
81
+ # @return [Hash] frozen schema
82
+ # @raise [ArgumentError] with meta: but no root:
83
+ # @raise [Versponse::DefinitionError] for a meta: that isn't a class with a $ref
84
+ # @raise [Versponse::VersionNotFoundError] when the class, or a Versponse meta: class, doesn't serve the version
85
+ # @raise [Versponse::ViewNotFoundError] for an unknown view (wrappers too)
86
+ def to_schema(version: nil, view: :default, root: nil, meta: nil, collection: false)
87
+ Wrapper.check_meta!(meta)
88
+ raise ArgumentError, "meta: requires root:" if meta && root.nil?
89
+
90
+ versponse.requesting(version) do |canonical|
91
+ next versponse.openapi_schema(version: canonical, view:) unless root
92
+
93
+ # The wrapper only refers to the components, but their versions and the view must exist like for any schema
94
+ versponse.resolve(canonical).properties_for(view)
95
+ meta.versponse.resolve(canonical) if References.versponse?(meta)
96
+ DeepFreeze.call(Wrapper.schema(self, view:, root:, meta:, collection:))
97
+ end
98
+ end
99
+
100
+ # Schema with every reference inlined, for validation
101
+ #
102
+ # @param version [String, nil] requested version, config.current_version when nil
103
+ # @param view [Symbol] view name
104
+ # @param property [Symbol, String, nil] returns only this property's schema
105
+ # @return [Hash] frozen schema
106
+ # @raise [Versponse::PropertyNotFoundError] for an unknown property
107
+ def to_validation_schema(version: nil, view: :default, property: nil)
108
+ versponse.requesting(version) do |canonical|
109
+ next versponse.property_validation_schema(version: canonical, view:, key: property.to_s) if property
110
+
111
+ versponse.validation_schema(version: canonical, view:)
112
+ end
113
+ end
114
+
115
+ # Component name without the view suffix: the class name without its first namespace, squashed.
116
+ # Override it to rename components (e.g. strip a "Response" suffix).
117
+ #
118
+ # @return [String] base component name
119
+ def base_component_name
120
+ name.to_s.split("::", 2).last.to_s.delete(":")
121
+ end
122
+
123
+ # @param view [Symbol] view name
124
+ # @return [String] component name, with the PascalCase view name for non-default views
125
+ def component_name(view: :default)
126
+ (view.to_sym == :default) ? base_component_name : base_component_name + Naming.pascalize(view)
127
+ end
128
+
129
+ # @param view [Symbol] view name
130
+ # @param property [Symbol, String, nil] points at one property
131
+ # @return [String] "#/components/schemas/<name>" reference
132
+ # @raise [Versponse::DefinitionError] for an anonymous class, which has no component name
133
+ def to_ref(view: :default, property: nil)
134
+ raise DefinitionError, "anonymous response classes have no component; name the class or override base_component_name" if base_component_name.empty?
135
+
136
+ base = "#/components/schemas/#{component_name(view:)}"
137
+ property ? "#{base}/properties/#{property}" : base
138
+ end
139
+
140
+ # Every expandable dotted path of a response, for scope checks and expand: validation
141
+ #
142
+ # @param version [String, nil] requested version, config.current_version when nil
143
+ # @param view [Symbol] view name
144
+ # @return [Array<String>] sorted, frozen paths such as ["deal.stage", "site"]
145
+ def expandable_paths(version: nil, view: :default)
146
+ versponse.requesting(version) { |canonical| versponse.expandable_paths(version: canonical, view:) }
147
+ end
148
+
149
+ # The response classes each expandable path renders, for metadata such as scopes declared on the classes
150
+ #
151
+ # @param version [String, nil] requested version, config.current_version when nil
152
+ # @param view [Symbol] view name
153
+ # @return [Hash{String => Array<Class>}] frozen, keyed like expandable_paths: a reference's from:, a
154
+ # collection's item: or every one_of variant's class, such as {"site" => [SiteResponse]}
155
+ def expandable_targets(version: nil, view: :default)
156
+ versponse.requesting(version) { |canonical| versponse.expandable_targets(version: canonical, view:) }
157
+ end
158
+
159
+ # Every dotted path serialize fields: accepts, for docs and scope checks
160
+ #
161
+ # @param version [String, nil] requested version, config.current_version when nil
162
+ # @param view [Symbol] view name
163
+ # @return [Array<String>] sorted, frozen paths such as ["id", "site", "site.name"]
164
+ def selectable_paths(version: nil, view: :default)
165
+ versponse.requesting(version) { |canonical| versponse.selectable_paths(version: canonical, view:) }
166
+ end
167
+
168
+ private
169
+
170
+ # @param object [Object] source, or a collection of them
171
+ # @param plan [#call] plan of one object
172
+ # @param context [Hash] frozen serialize context
173
+ # @param expansion [Versponse::Expansion, Versponse::Selection::Scope] expand tree, paired with the selection in a
174
+ # selected render
175
+ # @return [Hash, Array<Hash>] rendered output
176
+ def render(object, plan, context, expansion)
177
+ Collections.collection?(object) ? Collections.map(Collections.items(object)) { |item| plan.call(item, context, expansion) } : plan.call(object, context, expansion)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Class-level DSL of {Versponse::Base}. All methods are private class methods; they delegate to the
5
+ # class's {Versponse::Builder}, which holds the definition state.
6
+ module Base::DSL
7
+ private
8
+
9
+ # Sets the description of the class or the current version
10
+ #
11
+ # @param text [String] description
12
+ # @return [void]
13
+ def description(text)
14
+ versponse.builder.description(text)
15
+ end
16
+
17
+ # Adds schema keywords to the class or the current version
18
+ #
19
+ # @param options [Hash] JSON Schema keywords in snake_case
20
+ # @return [void]
21
+ def schema_options(**options)
22
+ versponse.builder.schema_options(**options)
23
+ end
24
+
25
+ # Limits the versions the class serves; resolving a version outside the range raises
26
+ # VersionNotFoundError. Subclasses inherit the range; no arguments removes an inherited one.
27
+ #
28
+ # @param min [String, nil] first supported version (inclusive), defaults to the first declared version
29
+ # @param max [String, nil] last supported version (inclusive), defaults to no upper bound
30
+ # @return [void]
31
+ # @raise [Versponse::DefinitionError] when min sorts after max, inside a version, or after the first version
32
+ def supported_versions(min: nil, max: nil)
33
+ versponse.builder.supported_versions(min:, max:)
34
+ end
35
+
36
+ # Sets the fraction digits of date-time values, replacing the date-time formatter of config.formatters.
37
+ # With from:, it applies from that version on. Subclasses inherit the settings and can add or replace them.
38
+ #
39
+ # @param digits [Integer] fraction digits from 0 (whole seconds) to 9
40
+ # @param from [String, nil] first version it applies to; nil for every version without a later setting
41
+ # @return [void]
42
+ # @raise [Versponse::DefinitionError] inside a version, after the first version, for invalid digits,
43
+ # or when the class already sets the precision from that version
44
+ def time_precision(digits, from: nil)
45
+ versponse.builder.time_precision(digits, from:)
46
+ end
47
+
48
+ # Replaces nil values of the class's field, enum and array properties without default:. Subclasses inherit it.
49
+ #
50
+ # @param callable [#call, nil] called with (object, key, context); nil removes an inherited one
51
+ # @return [void]
52
+ # @raise [Versponse::DefinitionError] inside a version, after the first version, or for a value that can't take
53
+ # (object, key, context)
54
+ def on_nil(callable)
55
+ versponse.builder.on_nil(callable)
56
+ end
57
+
58
+ # Policy for the class's failing properties without their own on_error:. Subclasses inherit it.
59
+ #
60
+ # @param policy [Symbol, #call, nil] :raise, :nullify, :omit, a handler called with (error, object, key, context),
61
+ # or nil to remove an inherited one
62
+ # @return [void]
63
+ # @raise [Versponse::DefinitionError] inside a version, after the first version, or for an unknown policy
64
+ def on_error(policy)
65
+ versponse.builder.on_error(policy)
66
+ end
67
+
68
+ # Declares a version
69
+ #
70
+ # @param name [String] version name, compared as a String
71
+ # @param inherit [Boolean, String, Symbol] true copies the previously declared version, a name copies the
72
+ # closest version at or before it
73
+ # @param exclude_properties [Array<Symbol>] inherited properties to leave out
74
+ # @return [void]
75
+ # @raise [Versponse::DefinitionError] when nested in another version
76
+ # @raise [Versponse::DefinitionError] when inherit: names a version before every declared one
77
+ def version(name, inherit: true, exclude_properties: [], &block)
78
+ versponse.builder.version(name, inherit:, exclude_properties:, &block)
79
+ end
80
+
81
+ # Computes the value of the enclosing inline object, inline collection or one_of
82
+ #
83
+ # @yieldparam object [Object] serialized object
84
+ # @yieldparam context [Hash] serialize context
85
+ # @return [void]
86
+ def source(&block)
87
+ versponse.builder.source(&block)
88
+ end
89
+
90
+ # Declares a scalar field
91
+ #
92
+ # @param name [Symbol] output key
93
+ # @param type [Symbol, Array<Symbol>] string, number, integer, boolean, object, a custom type, or a union like [:integer, :string]
94
+ # @param options [Hash] map:, nullable:, if:, unless:, JSON Schema keywords
95
+ # @yieldparam object [Object] serialized object
96
+ # @yieldparam context [Hash] serialize context
97
+ # @return [void]
98
+ def field(name, type: nil, **options, &block)
99
+ versponse.builder.field(name, type:, **options, &block)
100
+ end
101
+
102
+ # Declares a field of both the full shape and the collapsed shape, rendered for expandable associations that aren't expanded
103
+ #
104
+ # @param name [Symbol] output key
105
+ # @param type [Symbol, nil] field type (with_options type:, else :string)
106
+ # @param collapse_if [Proc, #call, nil] (not taken from with_options) called with (object) or (object, context); when it returns false the
107
+ # association renders in full instead (a collection item: when it is false for any item), and the full shape leaves this field out.
108
+ # One per version.
109
+ # @param options [Hash] map:, nullable:, description: and JSON Schema keywords (no if:/unless:); with_options defaults apply
110
+ # @return [void]
111
+ # @raise [Versponse::DefinitionError] outside a version, with if:/unless:, or with a collapse_if: that doesn't respond to call
112
+ def identifier(name, type: nil, collapse_if: nil, **options, &block)
113
+ versponse.builder.identifier(name, type:, collapse_if:, **options, &block)
114
+ end
115
+
116
+ # Declares a value from a fixed list
117
+ #
118
+ # @param name [Symbol] output key
119
+ # @param type [Symbol, Array<Symbol>, nil] type of the values, as for {#field} (with_options type:); inferred from the values when nil
120
+ # @param values [Array] allowed values, each of the type
121
+ # @param options [Hash] see {#field}
122
+ # @return [void]
123
+ # @raise [Versponse::DefinitionError] when the type can't be inferred, or a value isn't of the type
124
+ def enum(name, values:, type: nil, **options, &block)
125
+ versponse.builder.enum(name, values:, type:, **options, &block)
126
+ end
127
+
128
+ # Declares a constant
129
+ #
130
+ # @param name [Symbol] output key
131
+ # @param value [Object] constant value
132
+ # @param options [Hash] if:, unless:, JSON Schema keywords
133
+ # @return [void]
134
+ # @raise [Versponse::DefinitionError] with a block, map: or nullable: (a constant never reads the object)
135
+ def const(name, value:, **options, &block)
136
+ versponse.builder.const(name, value:, **options, &block)
137
+ end
138
+
139
+ # Declares an array of scalars
140
+ #
141
+ # @param name [Symbol] output key
142
+ # @param type [Symbol, Array<Symbol>] item type, or a union like [:integer, :string]
143
+ # @param item_schema_options [Hash] JSON Schema keywords of each item
144
+ # @param options [Hash] see {#field}
145
+ # @return [void]
146
+ def array(name, type: nil, item_schema_options: {}, **options, &block)
147
+ versponse.builder.array(name, type:, item_schema_options:, **options, &block)
148
+ end
149
+
150
+ # Declares an inline object; the block defines its properties
151
+ #
152
+ # @param name [Symbol] output key
153
+ # @param options [Hash] map: (use "." for the same object), nullable:, if:, unless:, JSON Schema keywords
154
+ # @return [void]
155
+ def object(name, **options, &block)
156
+ versponse.builder.object(name, **options, &block)
157
+ end
158
+
159
+ # Declares an array of objects, either of another response class (item:) or inline (block)
160
+ #
161
+ # With item:, a block computes the value. Without item:, the block defines the item properties.
162
+ #
163
+ # @param name [Symbol] output key
164
+ # @param item [Class, nil] class implementing the reference protocol
165
+ # @param view [Symbol, nil] view of item (with_options view:, else :default)
166
+ # @param options [Hash] see {#field}
167
+ # @return [void]
168
+ def collection(name, item: nil, view: nil, **options, &block)
169
+ versponse.builder.collection(name, item:, view:, **options, &block)
170
+ end
171
+
172
+ # Declares a nested response class, or a variant inside one_of
173
+ #
174
+ # @param name [Symbol, nil] output key, omitted inside one_of
175
+ # @param from [Class] class implementing the reference protocol
176
+ # @param view [Symbol, nil] view of from (with_options view:, else :default)
177
+ # @param property [Symbol, nil] one property of a non-Versponse class's schema
178
+ # @param match [Class, Proc, nil] one_of matcher, compared with ===
179
+ # @param options [Hash] see {#field}
180
+ # @return [void]
181
+ def reference(name = nil, from:, view: nil, property: nil, match: nil, **options, &block)
182
+ versponse.builder.reference(name, from:, view:, property:, match:, **options, &block)
183
+ end
184
+
185
+ # Declares a polymorphic value; the block declares variants with reference from:, match:.
186
+ # Without a name, inside a collection block, it types each item of the collection.
187
+ #
188
+ # @param positional_name [Symbol, nil] output key; nil for the item one_of of a collection
189
+ # @param name [Symbol, nil] the output key as a keyword, as verquest writes it
190
+ # @param discriminator [String, Symbol, nil] OpenAPI discriminator property
191
+ # @param options [Hash] see {#field}; an item one_of takes only nullable: and schema keywords
192
+ # @return [void]
193
+ # @raise [Versponse::DefinitionError] when the name is given both ways
194
+ def one_of(positional_name = nil, name: nil, discriminator: nil, **options, &block)
195
+ versponse.builder.one_of(positional_name, name:, discriminator:, **options, &block)
196
+ end
197
+
198
+ # Declares a view inside a version
199
+ #
200
+ # @param name [Symbol] view name
201
+ # @return [void]
202
+ def view(name, &block)
203
+ versponse.builder.view(name, &block)
204
+ end
205
+
206
+ # Applies another view's operations inside a view
207
+ #
208
+ # @param name [Symbol] included view
209
+ # @return [void]
210
+ def include_view(name)
211
+ versponse.builder.include_view(name)
212
+ end
213
+
214
+ # Removes properties from the current version or view
215
+ #
216
+ # @param names [Array<Symbol>] property names
217
+ # @return [void]
218
+ def exclude_properties(*names)
219
+ versponse.builder.exclude_properties(*names)
220
+ end
221
+
222
+ # Applies options to every property declared in the block. A nested with_options replaces the outer
223
+ # options, and map: is ignored, as in verquest.
224
+ #
225
+ # @param options [Hash] shared options
226
+ # @return [void]
227
+ def with_options(**options, &block)
228
+ versponse.builder.with_options(**options, &block)
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Parent of every response definition
5
+ #
6
+ # @example
7
+ # class UnitTypeResponse < Versponse::Base
8
+ # version "2025-09" do
9
+ # field :id, type: :string, map: "public_id"
10
+ # reference :site, from: SiteResponse, view: :embedded
11
+ #
12
+ # view :embedded do
13
+ # exclude_properties :site
14
+ # end
15
+ # end
16
+ # end
17
+ class Base
18
+ extend DSL
19
+ extend ClassMethods
20
+
21
+ # Registers the subclass for {Versponse.components} and {Versponse.reset_caches!}
22
+ #
23
+ # @param subclass [Class] new response class
24
+ # @return [void]
25
+ # @raise [Versponse::DefinitionError] when the parent already declares versions
26
+ def self.inherited(subclass)
27
+ super
28
+ if self != Versponse::Base && !versponse.versions.empty?
29
+ raise DefinitionError, "#{name} has versions and can not be subclassed; subclass a class without versions (e.g. an abstract BaseResponse)"
30
+ end
31
+
32
+ Registry.add(subclass)
33
+ end
34
+ end
35
+ end