verquest 0.6.4 → 0.7.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -1
  3. data/README.md +299 -45
  4. data/Rakefile +5 -0
  5. data/docs/rails.md +169 -0
  6. data/lib/verquest/base/condition_class_methods.rb +211 -0
  7. data/lib/verquest/base/private_class_methods.rb +31 -14
  8. data/lib/verquest/base/public_class_methods.rb +51 -1
  9. data/lib/verquest/base.rb +1 -0
  10. data/lib/verquest/conditions/branch.rb +111 -0
  11. data/lib/verquest/conditions/condition.rb +194 -0
  12. data/lib/verquest/conditions/conjunction.rb +39 -0
  13. data/lib/verquest/conditions/fragment_merge.rb +52 -0
  14. data/lib/verquest/conditions/property_merge.rb +47 -0
  15. data/lib/verquest/conditions/rule_set.rb +58 -0
  16. data/lib/verquest/conditions/rules/absent.rb +25 -0
  17. data/lib/verquest/conditions/rules/alternatives.rb +28 -0
  18. data/lib/verquest/conditions/rules/any.rb +27 -0
  19. data/lib/verquest/conditions/rules/equals.rb +22 -0
  20. data/lib/verquest/conditions/rules/names_rule.rb +23 -0
  21. data/lib/verquest/conditions/rules/none.rb +24 -0
  22. data/lib/verquest/conditions/rules/present.rb +22 -0
  23. data/lib/verquest/conditions/rules/property_rule.rb +48 -0
  24. data/lib/verquest/conditions/rules/satisfies.rb +22 -0
  25. data/lib/verquest/conditions/rules/value_in.rb +22 -0
  26. data/lib/verquest/conditions/rules.rb +17 -0
  27. data/lib/verquest/conditions.rb +36 -0
  28. data/lib/verquest/configuration.rb +19 -1
  29. data/lib/verquest/gem_version.rb +1 -1
  30. data/lib/verquest/helper_methods/conditional_properties.rb +126 -0
  31. data/lib/verquest/helper_methods/unnamed_one_of.rb +35 -0
  32. data/lib/verquest/properties/array.rb +15 -1
  33. data/lib/verquest/properties/base.rb +104 -10
  34. data/lib/verquest/properties/collection.rb +51 -18
  35. data/lib/verquest/properties/const.rb +2 -1
  36. data/lib/verquest/properties/object.rb +38 -4
  37. data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
  38. data/lib/verquest/properties/one_of.rb +83 -44
  39. data/lib/verquest/properties/reference.rb +47 -16
  40. data/lib/verquest/query/coercer.rb +165 -0
  41. data/lib/verquest/query/definition_check.rb +91 -0
  42. data/lib/verquest/query/parameters.rb +79 -0
  43. data/lib/verquest/query.rb +25 -0
  44. data/lib/verquest/transformer/collection.rb +32 -0
  45. data/lib/verquest/transformer/const_as_enum.rb +47 -0
  46. data/lib/verquest/transformer/layout.rb +83 -0
  47. data/lib/verquest/transformer/mapping_layout.rb +166 -0
  48. data/lib/verquest/transformer/one_of.rb +111 -0
  49. data/lib/verquest/transformer/path.rb +188 -0
  50. data/lib/verquest/transformer/plan.rb +183 -0
  51. data/lib/verquest/transformer/scope.rb +64 -0
  52. data/lib/verquest/transformer.rb +43 -880
  53. data/lib/verquest/version.rb +152 -79
  54. data/lib/verquest.rb +6 -0
  55. metadata +42 -7
data/docs/rails.md ADDED
@@ -0,0 +1,169 @@
1
+ # Rails and rswag
2
+
3
+ Verquest doesn't depend on Rails, so it ships no Rails integration. Its three locations map onto Rails request methods, though: `location :body` (the default) reads `request_parameters`, `location :query` reads `query_parameters`, and `location :path` reads `path_parameters`. This guide gives you two small pieces to copy into your app:
4
+
5
+ - a controller concern that processes all three from one declaration per class;
6
+ - an rswag shared context that documents query and path classes in your request specs.
7
+
8
+ ## API version
9
+
10
+ Verquest picks the request version from `config.current_version`. With the version in the URL (`/2025-06/deals`), keep it in `Current` and read it from there:
11
+
12
+ ```ruby
13
+ # config/routes.rb
14
+ scope "/:api_version", constraints: {api_version: /\d{4}-\d{2}/} do
15
+ resources :deals, only: %i[index show update] do
16
+ resource :win, only: :create, module: :deals
17
+ end
18
+ end
19
+
20
+ # app/models/current.rb
21
+ class Current < ActiveSupport::CurrentAttributes
22
+ attribute :api_version
23
+ end
24
+
25
+ # config/initializers/verquest.rb
26
+ Verquest.configure do |config|
27
+ config.current_version = -> { Current.api_version }
28
+ end
29
+ ```
30
+
31
+ ## Controller concern
32
+
33
+ ```ruby
34
+ # app/controllers/concerns/verquest_params.rb
35
+ module VerquestParams
36
+ extend ActiveSupport::Concern
37
+
38
+ included do
39
+ before_action :process_verquest_params
40
+ attr_reader :body_params, :query_params, :path_params
41
+
42
+ # Keep JSON bodies as sent, without a copy under the controller's name
43
+ wrap_parameters false
44
+ end
45
+
46
+ class_methods do
47
+ # `param:` is the route key a path class's `id` comes from
48
+ def verquest_params(request_class, param: :id, only: nil, except: nil)
49
+ verquest_declarations << {class: request_class, param:, only: Array(only), except: Array(except)}
50
+ end
51
+
52
+ def verquest_declarations
53
+ @verquest_declarations ||= []
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def process_verquest_params
60
+ declarations = self.class.verquest_declarations.select { |declaration| verquest_declaration_applies?(declaration) }
61
+ by_location = declarations.group_by { |declaration| declaration[:class].location }
62
+
63
+ # Every path class applies, so /deals/:deal_id/notes/:id can check both IDs
64
+ @path_params = by_location.fetch(:path, []).to_h { |declaration| process_path_param(declaration) }
65
+ # Unknown query keys (cache busters, tracking) are dropped instead of rejected
66
+ @query_params = by_location[:query]&.first&.dig(:class)&.process(request.query_parameters, remove_extra_root_keys: true)
67
+ @body_params = by_location[:body]&.first&.dig(:class)&.process(request.request_parameters)
68
+ end
69
+
70
+ def process_path_param(declaration)
71
+ param = declaration[:param]
72
+ [param.to_s, declaration[:class].process({"id" => request.path_parameters[param]})["id"]]
73
+ rescue Verquest::InvalidParamsError => e
74
+ # Point errors at the route key (/deal_id), not the class's /id
75
+ errors = e.errors.map { |error| error.merge(pointer: "/#{param}", message: error[:message].sub("`/id`", "`/#{param}`")) }
76
+ raise Verquest::InvalidParamsError.new(e.message, errors:)
77
+ end
78
+
79
+ def verquest_declaration_applies?(declaration)
80
+ action = action_name.to_sym
81
+ (declaration[:only].empty? || declaration[:only].include?(action)) && declaration[:except].exclude?(action)
82
+ end
83
+ end
84
+ ```
85
+
86
+ The concern expects `config.validation_error_handling = :raise` (the default). Set the version and render errors once, in the base controller:
87
+
88
+ ```ruby
89
+ class ApplicationController < ActionController::API
90
+ # Declared before the include, so the version is set when the concern's before_action runs
91
+ before_action { Current.api_version = params[:api_version] }
92
+ include VerquestParams
93
+
94
+ rescue_from Verquest::InvalidParamsError do |error|
95
+ render json: {errors: error.errors}, status: :unprocessable_content
96
+ end
97
+ end
98
+ ```
99
+
100
+ Each path class validates one ID as `id`, so one class serves both the member route (`/deals/:id`) and nested routes (`/deals/:deal_id/win`):
101
+
102
+ ```ruby
103
+ class DealPath < Verquest::Base
104
+ location :path
105
+
106
+ version "2025-06" do
107
+ field :id, type: :string, required: true, pattern: "^deal_", description: "The ID of the deal"
108
+ end
109
+ end
110
+ ```
111
+
112
+ ```ruby
113
+ class DealsController < ApplicationController
114
+ verquest_params DealPath, only: %i[show update]
115
+ verquest_params DealsIndexQuery, only: :index
116
+ verquest_params DealUpdateRequest, only: :update
117
+
118
+ def show
119
+ render json: Deal.find_by!(public_id: path_params["id"])
120
+ end
121
+ end
122
+
123
+ class Deals::WinsController < ApplicationController
124
+ verquest_params DealPath, param: :deal_id # POST /deals/:deal_id/win
125
+ end
126
+ ```
127
+
128
+ `GET /deals/con_123` then returns 422 with `{"pointer" => "/id", ...}`, and `POST /deals/con_123/win` with `{"pointer" => "/deal_id", ...}`.
129
+
130
+ ## rswag
131
+
132
+ A shared context documents a query or path class with `to_parameters`. `name:` renames a path class's parameter to the route key, and `description:` replaces its description:
133
+
134
+ ```ruby
135
+ # spec/support/verquest.rb
136
+ RSpec.shared_context "verquest parameters" do |request_class, version:, name: nil, description: nil|
137
+ request_class.to_parameters(version:).each do |attributes|
138
+ parameter(attributes.merge({name:, description:}.compact))
139
+ end
140
+ end
141
+ ```
142
+
143
+ ```ruby
144
+ path "/2025-06/deals/{deal_id}" do
145
+ include_context "verquest parameters", DealPath, version: "2025-06", name: "deal_id"
146
+
147
+ patch "Update a deal" do
148
+ include_context "verquest parameters", ExpandQuery, version: "2025-06"
149
+ parameter name: :payload, in: :body, schema: {"$ref" => DealUpdateRequest.to_ref}
150
+
151
+ response "200", "deal updated" do
152
+ let(:deal_id) { create(:deal).public_id }
153
+ let(:payload) { {name: "New name"} }
154
+
155
+ run_test!
156
+ end
157
+ end
158
+ end
159
+ ```
160
+
161
+ rswag skips optional query parameters without a matching `let`, so specs only define the ones they send.
162
+
163
+ Body classes are registered as components in `swagger_helper.rb` (see [Component Generation for OpenAPI](../README.md#component-generation-for-openapi)):
164
+
165
+ ```ruby
166
+ components: {
167
+ schemas: [DealUpdateRequest].to_h { |request| [request.component_name, request.to_schema(version: "2025-06")] }
168
+ }
169
+ ```
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ # DSL class methods for conditional schemas (`if` / `then` / `else`)
5
+ #
6
+ # `condition` opens a {Verquest::Conditions::Condition} on the current scope.
7
+ # `given`, `then_apply` and `else_apply` make its parts the current scope, so
8
+ # `field`, `object` and the rest add branch properties exactly as they add
9
+ # regular ones, and the rule methods add rules.
10
+ #
11
+ # @example
12
+ # version "2025-09" do
13
+ # field :kind, type: :string
14
+ #
15
+ # condition :company do
16
+ # given { equals :kind, "company" }
17
+ # then_apply { field :vat_id, type: :string, required: true }
18
+ # end
19
+ # end
20
+ #
21
+ # @api private
22
+ module Base::ConditionClassMethods
23
+ private
24
+
25
+ # Declares a condition on the current scope
26
+ #
27
+ # @param name [Symbol, nil] Optional name, used to exclude the condition in a later version
28
+ # @param document_as [Symbol, nil] `:one_of` to document the condition as a oneOf of its branches
29
+ # in `to_schema`, while validation keeps `if` / `then` / `else`. Only for a condition in a version.
30
+ # @yield Block declaring `given`, `then_apply` and `else_apply`
31
+ # @return [void]
32
+ # @raise [ArgumentError] If the current scope can't hold conditions, or document_as is used outside a version
33
+ def condition(name = nil, document_as: nil, &block)
34
+ raise ArgumentError, "condition can only be declared in a version, object or inline collection" unless current_scope.respond_to?(:add_condition)
35
+ raise ArgumentError, "document_as: :one_of can only be used on a condition in a version" if document_as && !current_scope.is_a?(Version)
36
+
37
+ condition = Conditions::Condition.new(name:, document_as:)
38
+ current_scope.add_condition(condition)
39
+ within_scope(condition, &block)
40
+ end
41
+
42
+ # Declares the `if` of the current condition
43
+ #
44
+ # @yield Block declaring rules
45
+ # @return [void]
46
+ def given(&block)
47
+ open_condition_part(:given, &block)
48
+ end
49
+
50
+ # Declares the `then` of the current condition
51
+ #
52
+ # The title, description and deprecated flag document the branch's
53
+ # variant when the condition is declared with `document_as: :one_of`.
54
+ #
55
+ # @param title [String, nil] Title of the branch's oneOf variant
56
+ # @param description [String, nil] Description of the branch's oneOf variant
57
+ # @param deprecated [Boolean, nil] Whether the branch's oneOf variant is deprecated
58
+ # @yield Block declaring rules and properties
59
+ # @return [void]
60
+ def then_apply(title: nil, description: nil, deprecated: nil, &block)
61
+ open_condition_part(:then, title:, description:, deprecated:, &block)
62
+ end
63
+
64
+ # Declares the `else` of the current condition
65
+ #
66
+ # The title, description and deprecated flag document the branch's
67
+ # variant when the condition is declared with `document_as: :one_of`.
68
+ #
69
+ # @param title [String, nil] Title of the branch's oneOf variant
70
+ # @param description [String, nil] Description of the branch's oneOf variant
71
+ # @param deprecated [Boolean, nil] Whether the branch's oneOf variant is deprecated
72
+ # @yield Block declaring rules and properties
73
+ # @return [void]
74
+ def else_apply(title: nil, description: nil, deprecated: nil, &block)
75
+ open_condition_part(:else, title:, description:, deprecated:, &block)
76
+ end
77
+
78
+ # Declares a condition that applies the block when a property is present
79
+ #
80
+ # Shorthand for `condition(name) { given { present key }; then_apply { ... } }`.
81
+ #
82
+ # @param key [Symbol] The property whose presence triggers the block
83
+ # @param name [Symbol, nil] Optional condition name
84
+ # @yield Block declaring rules and properties, like `then_apply`
85
+ # @return [void]
86
+ def depends_on(key, name = nil, &block)
87
+ condition(name) do
88
+ given { present key }
89
+ then_apply(&block)
90
+ end
91
+ end
92
+
93
+ # Removes inherited named conditions from the current version
94
+ #
95
+ # A named condition can be declared only once per version, so exclude an
96
+ # inherited one before declaring a condition with the same name.
97
+ #
98
+ # @param names [Array<Symbol>] The condition names
99
+ # @return [void]
100
+ # @raise [ArgumentError] If a condition is not defined
101
+ def exclude_conditions(*names)
102
+ names.each { |name| current_scope.remove_condition(name) }
103
+ end
104
+
105
+ # Requires the named properties to be present
106
+ #
107
+ # @param names [Array<Symbol>] The property names
108
+ # @return [void]
109
+ def present(*names)
110
+ add_rule(Conditions::Rules::Present.new(names))
111
+ end
112
+
113
+ # Forbids the named properties
114
+ #
115
+ # @param names [Array<Symbol>] The property names
116
+ # @return [void]
117
+ def absent(*names)
118
+ add_rule(Conditions::Rules::Absent.new(names))
119
+ end
120
+
121
+ # Requires a property to hold one value
122
+ #
123
+ # @param name [Symbol] The property name
124
+ # @param value [Object] The value
125
+ # @return [void]
126
+ def equals(name, value)
127
+ add_rule(Conditions::Rules::Equals.new(name, value))
128
+ end
129
+
130
+ # Requires a property to hold one of several values
131
+ #
132
+ # @param name [Symbol] The property name
133
+ # @param values [Array] The values
134
+ # @return [void]
135
+ def value_in(name, values)
136
+ add_rule(Conditions::Rules::ValueIn.new(name, values))
137
+ end
138
+
139
+ # Requires a property to match schema options (camelized like field options)
140
+ #
141
+ # @param name [Symbol] The property name
142
+ # @param schema_options [Hash] The schema options
143
+ # @return [void]
144
+ def satisfies(name, **schema_options)
145
+ add_rule(Conditions::Rules::Satisfies.new(name, camelize(schema_options)))
146
+ end
147
+
148
+ # Requires at least one of the rules in the block to hold
149
+ #
150
+ # @yield Block declaring the alternative rules
151
+ # @return [void]
152
+ def any(&block)
153
+ add_rule_set(Conditions::Rules::Any.new, &block)
154
+ end
155
+
156
+ # Requires none of the rules in the block to hold
157
+ #
158
+ # @yield Block declaring the rules
159
+ # @return [void]
160
+ def none(&block)
161
+ add_rule_set(Conditions::Rules::None.new, &block)
162
+ end
163
+
164
+ # Makes a part of the current condition the scope for the block
165
+ #
166
+ # @param part [Symbol] :given, :then or :else
167
+ # @yield Block declaring the part
168
+ # @return [void]
169
+ # @raise [ArgumentError] If the current scope is not a condition
170
+ def open_condition_part(part, **options, &block)
171
+ raise ArgumentError, "given, then_apply and else_apply can only be used inside a condition" unless current_scope.is_a?(Conditions::Condition)
172
+
173
+ within_scope(current_scope.open(part, **options), &block)
174
+ end
175
+
176
+ # Adds a rule to the current scope
177
+ #
178
+ # @param rule [#to_schema, #property_names] The rule
179
+ # @return [void]
180
+ # @raise [ArgumentError] If the current scope can't hold rules
181
+ def add_rule(rule)
182
+ raise ArgumentError, "Rules can only be used inside given, then_apply, else_apply, depends_on, any or none" unless current_scope.respond_to?(:add_rule)
183
+
184
+ current_scope.add_rule(rule)
185
+ end
186
+
187
+ # Adds a rule that holds other rules and makes it the scope for the block
188
+ #
189
+ # @param rule_set [Verquest::Conditions::Rules::Any] The rule
190
+ # @yield Block declaring the nested rules
191
+ # @return [void]
192
+ def add_rule_set(rule_set, &block)
193
+ add_rule(rule_set)
194
+ within_scope(rule_set, &block)
195
+ end
196
+
197
+ # Runs the block with another current scope
198
+ #
199
+ # @param scope [Object] The scope for the block
200
+ # @yield Block evaluated in the class context
201
+ # @return [void]
202
+ def within_scope(scope, &block)
203
+ previous_scope = current_scope
204
+ @current_scope = scope
205
+
206
+ instance_exec(&block)
207
+ ensure
208
+ @current_scope = previous_scope
209
+ end
210
+ end
211
+ end
@@ -48,7 +48,7 @@ module Verquest
48
48
  # @yield Block defining the version's structure and properties
49
49
  # @return [void]
50
50
  def version(name, inherit: true, exclude_properties: [], &block)
51
- version = Version.new(name:)
51
+ version = Version.new(name:, location:)
52
52
  versions.add(version)
53
53
 
54
54
  if inherit && @current_scope
@@ -125,8 +125,9 @@ module Verquest
125
125
  # @param required [Boolean, Array<Symbol>] Whether the field is required
126
126
  # @param nullable [Boolean] Whether the field can be null
127
127
  # @param schema_options [Hash] Additional schema options for the field
128
+ # @param parameter_examples [Hash, nil] Named OpenAPI Example Objects for the query parameter (query requests only)
128
129
  # @return [void]
129
- def field(name, type: nil, map: nil, required: nil, nullable: nil, **schema_options)
130
+ def field(name, type: nil, map: nil, required: nil, nullable: nil, parameter_examples: nil, **schema_options)
130
131
  camelize(schema_options)
131
132
 
132
133
  type = default_options.fetch(:type, type)
@@ -135,7 +136,7 @@ module Verquest
135
136
  schema_options = default_options.except(:type, :required, :nullable).merge(schema_options)
136
137
 
137
138
  field = Properties::Field.new(name:, type:, map:, required:, nullable:, **schema_options)
138
- current_scope.add(field)
139
+ add_property(field, parameter_examples)
139
140
  end
140
141
 
141
142
  # Defines a new enum property for the current version scope
@@ -146,8 +147,9 @@ module Verquest
146
147
  # @param required [Boolean, Array<Symbol>] Whether the enum is required
147
148
  # @param nullable [Boolean] Whether the enum can be null
148
149
  # @param schema_options [Hash] Additional schema options for the enum
150
+ # @param parameter_examples [Hash, nil] Named OpenAPI Example Objects for the query parameter (query requests only)
149
151
  # @return [void]
150
- def enum(name, values:, map: nil, required: nil, nullable: nil, **schema_options)
152
+ def enum(name, values:, map: nil, required: nil, nullable: nil, parameter_examples: nil, **schema_options)
151
153
  camelize(schema_options)
152
154
 
153
155
  required = default_options.fetch(:required, false) if required.nil?
@@ -155,7 +157,7 @@ module Verquest
155
157
  schema_options = default_options.except(:required, :nullable).merge(schema_options)
156
158
 
157
159
  enum_property = Properties::Enum.new(name:, values:, map:, required:, nullable:, **schema_options)
158
- current_scope.add(enum_property)
160
+ add_property(enum_property, parameter_examples)
159
161
  end
160
162
 
161
163
  # Defines a new constant property for the current version scope
@@ -166,14 +168,15 @@ module Verquest
166
168
  # @param required [Boolean, Array<Symbol>] Whether the constant is required
167
169
  # @param nullable [Boolean] Whether the constant can be null
168
170
  # @param schema_options [Hash] Additional schema options for the constant
171
+ # @param parameter_examples [Hash, nil] Named OpenAPI Example Objects for the query parameter (query requests only)
169
172
  # @return [void]
170
- def const(name, value:, map: nil, required: nil, nullable: nil, **schema_options)
173
+ def const(name, value:, map: nil, required: nil, nullable: nil, parameter_examples: nil, **schema_options)
171
174
  camelize(schema_options)
172
175
  required = default_options.fetch(:required, false) if required.nil?
173
176
  nullable = default_options.fetch(:nullable, false) if nullable.nil?
174
177
 
175
178
  const = Properties::Const.new(name:, value:, map:, required:, nullable:, **schema_options)
176
- current_scope.add(const)
179
+ add_property(const, parameter_examples)
177
180
  end
178
181
 
179
182
  # Defines a new object for the current version scope
@@ -183,9 +186,10 @@ module Verquest
183
186
  # @param required [Boolean, Array<Symbol>] Whether the object is required
184
187
  # @param nullable [Boolean] Whether the object can be null
185
188
  # @param schema_options [Hash] Additional schema options for the object
189
+ # @param parameter_examples [Hash, nil] Named OpenAPI Example Objects for the query parameter (query requests only)
186
190
  # @yield Block executed in the context of the new object definition
187
191
  # @return [void]
188
- def object(name, map: nil, required: nil, nullable: nil, **schema_options, &block)
192
+ def object(name, map: nil, required: nil, nullable: nil, parameter_examples: nil, **schema_options, &block)
189
193
  unless block_given?
190
194
  raise ArgumentError, "a block must be given to define the object"
191
195
  end
@@ -196,7 +200,7 @@ module Verquest
196
200
  schema_options = default_options.except(:type, :required, :nullable).merge(schema_options)
197
201
 
198
202
  object = Properties::Object.new(name:, map:, required:, nullable:, **schema_options)
199
- current_scope.add(object)
203
+ add_property(object, parameter_examples)
200
204
 
201
205
  if block_given?
202
206
  previous_scope = current_scope
@@ -251,13 +255,14 @@ module Verquest
251
255
  # @param map [String, nil] An optional mapping to another reference
252
256
  # @param required [Boolean, Array<Symbol>] Whether the reference is required
253
257
  # @param nullable [Boolean] Whether this reference can be null
258
+ # @param parameter_examples [Hash, nil] Named OpenAPI Example Objects for the query parameter (query requests only)
254
259
  # @return [void]
255
- def reference(name, from:, property: nil, map: nil, required: nil, nullable: nil)
260
+ def reference(name, from:, property: nil, map: nil, required: nil, nullable: nil, parameter_examples: nil)
256
261
  required = default_options.fetch(:required, false) if required.nil?
257
262
  nullable = default_options.fetch(:nullable, false) if nullable.nil?
258
263
 
259
264
  reference = Properties::Reference.new(name:, from:, property:, map:, required:, nullable:)
260
- current_scope.add(reference)
265
+ add_property(reference, parameter_examples)
261
266
  end
262
267
 
263
268
  # Defines a new array property for the current version scope
@@ -267,10 +272,12 @@ module Verquest
267
272
  # @param required [Boolean, Array<Symbol>] Whether the array property is required
268
273
  # @param nullable [Boolean] Whether this array can be null
269
274
  # @param map [String, nil] An optional mapping to another array property
275
+ # @param explode [Boolean, nil] In query requests, whether items are sent as repeated keys (true) or comma-separated (false); nil uses the configuration
270
276
  # @param item_schema_options [Hash] Additional JSON schema options for the array items (merged with custom type options)
271
277
  # @param schema_options [Hash] Additional schema options for the array property
278
+ # @param parameter_examples [Hash, nil] Named OpenAPI Example Objects for the query parameter (query requests only)
272
279
  # @return [void]
273
- def array(name, type:, required: nil, nullable: nil, map: nil, item_schema_options: {}, **schema_options)
280
+ def array(name, type:, required: nil, nullable: nil, map: nil, explode: nil, parameter_examples: nil, item_schema_options: {}, **schema_options)
274
281
  camelize(schema_options)
275
282
 
276
283
  type = default_options.fetch(:type, type)
@@ -278,8 +285,8 @@ module Verquest
278
285
  nullable = default_options.fetch(:nullable, false) if nullable.nil?
279
286
  schema_options = default_options.except(:type, :required, :nullable).merge(schema_options)
280
287
 
281
- array = Properties::Array.new(name:, type:, required:, nullable:, map:, item_schema_options:, **schema_options)
282
- current_scope.add(array)
288
+ array = Properties::Array.new(name:, type:, required:, nullable:, map:, explode:, item_schema_options:, **schema_options)
289
+ add_property(array, parameter_examples)
283
290
  end
284
291
 
285
292
  # Defines a oneOf property for polymorphic schemas
@@ -312,6 +319,16 @@ module Verquest
312
319
  @current_scope = previous_scope if block_given?
313
320
  end
314
321
 
322
+ # Adds a property to the current scope
323
+ #
324
+ # @param property [Verquest::Properties::Base] The property to add
325
+ # @param parameter_examples [Hash, nil] Named OpenAPI Example Objects for the query parameter
326
+ # @return [void]
327
+ def add_property(property, parameter_examples)
328
+ property.parameter_examples = parameter_examples
329
+ current_scope.add(property)
330
+ end
331
+
315
332
  # Excludes specified properties from the current scope by removing them
316
333
  # from the version's property set
317
334
  #
@@ -6,6 +6,9 @@ module Verquest
6
6
  # This module contains class methods that handle parameter mapping, schema generation,
7
7
  # and validation functionality for Verquest API request objects.
8
8
  module Base::PublicClassMethods
9
+ # Where request params can come from
10
+ LOCATIONS = %i[body query path].freeze
11
+
9
12
  # Maps incoming parameters to the appropriate structure based on version mapping
10
13
  #
11
14
  # @param params [Hash] The parameters to be mapped
@@ -22,7 +25,8 @@ module Verquest
22
25
 
23
26
  params = params.dup
24
27
  params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
25
- params = params.slice(*version_class.properties.keys) if remove_extra_root_keys && !version_class.combination?
28
+ params = params.slice(*version_class.all_properties.keys) if remove_extra_root_keys && !version_class.combination?
29
+ params = version_class.coerce_params(params)
26
30
 
27
31
  if validate && (validation_result = version_class.validate_params(params: params)) && validation_result.any?
28
32
  case Verquest.configuration.validation_error_handling
@@ -52,6 +56,52 @@ module Verquest
52
56
  end
53
57
  end
54
58
 
59
+ # Sets or returns where the request's params come from
60
+ #
61
+ # A query request (`location :query`) or path request (`location :path`)
62
+ # converts strings to the types its schema expects before validating them,
63
+ # and documents its properties with {#to_parameters}. Subclasses inherit
64
+ # the location.
65
+ #
66
+ # @example
67
+ # class ListQuery < Verquest::Base
68
+ # location :query
69
+ #
70
+ # version "2025-06" do
71
+ # field :page, type: :integer
72
+ # end
73
+ # end
74
+ #
75
+ # @param value [Symbol, nil] `:body`, `:query` or `:path` to set the location, nil to return it
76
+ # @return [Symbol] The location, `:body` unless set on this class or a superclass
77
+ # @raise [ArgumentError] If the value is not a known location, or a version is already defined
78
+ def location(value = nil)
79
+ return @location || (superclass.respond_to?(:location) ? superclass.location : :body) if value.nil?
80
+
81
+ raise ArgumentError, "Location must be one of #{LOCATIONS.join(", ")}" unless LOCATIONS.include?(value)
82
+ raise ArgumentError, "Location must be set before the first version" if current_scope
83
+
84
+ @location = value
85
+ end
86
+
87
+ # Returns OpenAPI parameter objects for a query or path request
88
+ #
89
+ # One parameter per property, with symbol keys, ready for rswag's `parameter`.
90
+ # Schemas are inlined, arrays get `style: :form` and their explode value, and
91
+ # objects get `style: :deepObject`.
92
+ #
93
+ # @example
94
+ # ListQuery.to_parameters(version: "2025-06").each { |p| parameter(p) }
95
+ #
96
+ # @param version [String, nil] Specific version to use, defaults to configuration setting
97
+ # @return [Array<Hash>] The OpenAPI parameter objects
98
+ # @raise [ArgumentError] If the request is a body request
99
+ def to_parameters(version: nil)
100
+ raise ArgumentError, "#{name} has no parameters (location #{location.inspect})" if location == :body
101
+
102
+ resolve(version).parameters
103
+ end
104
+
55
105
  # Returns the JSON schema for the request
56
106
  #
57
107
  # @param version [String, nil] Specific version to use, defaults to configuration setting
data/lib/verquest/base.rb CHANGED
@@ -33,6 +33,7 @@ module Verquest
33
33
  class Base
34
34
  extend Base::HelperClassMethods
35
35
  extend Base::PrivateClassMethods
36
+ extend Base::ConditionClassMethods
36
37
  extend Base::PublicClassMethods
37
38
  end
38
39
  end