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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5be764c988f4ea26a04112ed68684fcc7d292e25c37c6c2105f1c62ba7c92bc8
4
- data.tar.gz: 15cead4ea24fc972317e15157fd14194098f2f543ae7d2402ae81a443ad198d9
3
+ metadata.gz: 8bc88f55be50f80f099c989c617954b1600ed5d61adfcf35fccd430fbbce21cc
4
+ data.tar.gz: 8741893b6b9c3d5e91db1b6d6c3e3bf72e84ac43babd34f62a04859ee5bf6a7e
5
5
  SHA512:
6
- metadata.gz: 898d20e10c9ea20ccacd10fb8711778444f7cc096c09152e6ba48a167e363a19e60a2d8ed481205d2331b3b5a076ccc888a279ba44121342e698ade944ff9c8b
7
- data.tar.gz: 23dba503f13f33a571f65008eaa13f2f94347dd974cfa45e9e8bb2f76e9e2599e0ab19fed299b2ea43144641e642c54a30d636ffe0d332967cb7ec84c521f3d6
6
+ metadata.gz: d77a640d8afade1d2578899ef72a39550d870452f16e81bfef9c2aa61506c1b09aea07e7f23b3ef35ccc38218295c620389c1cda3e6eb9cdf1bb3227638358b1
7
+ data.tar.gz: 308cdf6806df22ec89108911cfe9a6e351e9b9c3c7b925a8a15dd31c108adc19f51d8f361528ecd566e057042b1382603ec6b3652e4f9604a5e45c711e7d3d81
data/CHANGELOG.md CHANGED
@@ -1,6 +1,48 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.6.3] - 2026-09-23
3
+ ## [0.7.0] - 2026-11-05
4
+
5
+ ### Added
6
+ - Query parameters: `location :query` on a request class converts query strings to the types its schema expects (numbers, booleans, null, comma-separated or repeated arrays, one level of `deepObject`) before `process` validates and maps them. `to_parameters(version:)` returns OpenAPI parameter objects with symbol keys for rswag. Arrays take `explode:`, defaulting to the new `config.query_array_explode` (`true`). `parameter_examples:` on `field`, `enum`, `const`, `array`, `object` and `reference` adds named OpenAPI examples to the parameter without putting them in the JSON schemas. Shapes query strings can't express raise the new `Verquest::DefinitionError` when the version is defined.
7
+ - Path parameters: `location :path` converts and validates path params like query params, and `to_parameters` documents them as `in: :path`. Properties must be required scalars, otherwise the version raises `Verquest::DefinitionError`.
8
+ - Conditional schemas: `condition` with `given`, `then_apply` and `else_apply` compiles to JSON Schema `if` / `then` / `else` in a version, an `object` or an inline `collection`. Rules: `present`, `absent`, `equals`, `value_in`, `satisfies`, `any` and `none`. Branch properties are listed in the scope's `properties` (so `additionalProperties: false` keeps working and the mapping stays flat), and each branch forbids the properties only the other branch declares, which gives one validation error per problem. Also adds the `depends_on` shorthand and `exclude_conditions` for later versions. Conditions need `json_schema_version` `:draft7` or later, or `:openapi31`; other versions raise `ArgumentError`.
9
+ - [Rails and rswag guide](docs/rails.md): a controller concern for body, query and path classes, and an rswag shared context.
10
+ - `condition ..., document_as: :one_of` documents a condition in a version as a `oneOf` of its branches in `to_schema`, while the validation schema keeps `if` / `then` / `else`. `then_apply` and `else_apply` take `title`, `description` and `deprecated` for their variant.
11
+
12
+ ### Changed
13
+ - A `one_of` with a `discriminator:` keeps the discriminator in the validation schema. Its variants move into local `definitions` referenced by `$ref`, under a content-hash id written as both `$id` and `id` (`https://verquest.local/one_of/...`). With `json_schema_version = :openapi31` or `:openapi30`, validation errors come only from the variant the discriminator selects, and a missing, unknown or non-object value gets a single `discriminator` error. Under `:openapi30`, valid payloads that were rejected with `oneOf` errors (OpenAPI 3.0 has no `const`, so they matched every variant) now pass. Other versions validate as before, but `to_validation_schema` output changes. If you register these schemas with another validator, note the new ids.
14
+ - The transformer builds one plan at initialization and is frozen. `Verquest::NOT_FOUND` moved to `Verquest::Transformer::Path::NOT_FOUND`. In the mapping, `_variant_path` and `_nullable_path` hold the full source path inside collections too (`entries[]/content` instead of `content`).
15
+ - A `null` oneOf maps to `null` whether it has a discriminator or not, and inside multiple oneOfs.
16
+ - A oneOf inside a `collection` is listed in the mapping's `_oneOfs` array, like several oneOfs, instead of putting its metadata at the top level.
17
+ - A oneOf variant property moved out of a collection item with `map: "/..."` raises `Verquest::MappingError` when the class is defined. Each item selects its own variant, so it can't be collected outside the items.
18
+ - The transformer allocates two to four times fewer objects per call and runs 15–40% faster. `rake bench` measures it.
19
+ - A version compiles its validation schema once per configuration instead of on every `process`, which makes `process` with validation up to 4 times faster.
20
+ - Under `json_schema_version` `:draft4` and `:openapi30`, which have no `const`, a `const` property is written as a one-value `enum` in `to_schema` and `to_validation_schema`, so it is enforced.
21
+ - An unnamed `one_of` next to other properties raises `ArgumentError` when it is declared. It was dropped from the schema.
22
+ - Versions build their transformer from their properties instead of from their `mapping` hash, so the transformer no longer depends on the mapping's metadata keys.
23
+
24
+ ### Deprecated
25
+ - `Verquest::Transformer.new(mapping:, discriminator:)`. Use `MyRequest.process`, or `MyRequest.resolve(version).transformer`; versions build their transformer from their properties, and the mapping constructor will be removed in 1.0.0. It warns when deprecation warnings are on (`Warning[:deprecated] = true` or `ruby -W:deprecated`), which Ruby leaves off by default.
26
+
27
+ ### Fixed
28
+ - A `one_of` inside an `object` mapped a valid payload to an empty hash, and two of them clashed. It also works inside an object in a collection.
29
+ - Empty collection items are kept (they were dropped, or became `nil` before a later item), and a `null` nested object inside an item stays `null`.
30
+ - A `null` nullable oneOf with a nested `map:` (e.g. `"billing/payment"`) is written as a nested key, not as the literal key `"billing/payment"`.
31
+ - A collection item without its optional oneOf, or with a `null` nullable one, no longer raises "No matching schema found". A nullable oneOf without a discriminator in a collection can be defined (the duplicate-mapping check counted its metadata).
32
+ - Properties whose names start with `_` are mapped next to oneOfs and inverted in `external_mapping`.
33
+ - A collection with `map:` and a oneOf no longer nests every item under the collection name again, also when a variant's first property is moved out with `map: "/..."`.
34
+ - Without validation, a present oneOf without its discriminator raises "Missing discriminator" instead of mapping to an empty hash, and an unknown value raises in collection items too. A value naming a metadata key no longer crashes with `NoMethodError`.
35
+ - Variant inference (a oneOf without a discriminator) reads the variant schemas in the configured `json_schema_version`. Under draft 4, a boolean `exclusive_maximum` crashed it. Under draft 4 and OpenAPI 3.0, which have no `const`, it reads `const` as a one-value `enum`, so variants that differ only by a const are still told apart.
36
+ - oneOfs in a collection inside a collection, and in several collections of one version, are mapped. Before, the discriminator read an array of values, and the second collection's oneOf overwrote the first one's.
37
+ - A property moved out of collection items with `map: "/..."` writes `null` for items without the value, instead of an internal object. In a collection holding a oneOf, it is collected outside the items again.
38
+ - `external_mapping` inverts the oneOfs nested in objects and collections, which were left as a hash key.
39
+ - A `reference`, or a `collection` with `item:`, to a request holding a oneOf no longer drops its data.
40
+ - A oneOf inside a oneOf variant, inline or referenced, maps its data, and so does a collection of oneOfs inside a variant. The data was dropped. In `mapping` and `external_mapping`, such a nested oneOf is listed in the variant's `_oneOfs` array, where it used to show up as prefixed metadata keys and stringified hashes.
41
+ - In `mapping` and `external_mapping`, an absolute `map:` in a request used as a oneOf reference variant points to the root, as the transformer always mapped it. It was nested under the variant.
42
+ - A `reference` with `property:` to an object, a collection or a oneOf maps its data. Only scalar properties worked.
43
+ - A collection whose only property is a named `one_of` keeps the name in its item schema, so valid items pass validation.
44
+
45
+ ## [0.6.4] - 2026-09-23
4
46
 
5
47
  ### Fixed
6
48
  - Handling `nullable: true` ([#18](https://github.com/verquest/verquest/pull/18), [@CiTroNaK](https://github.com/CiTroNaK))
data/README.md CHANGED
@@ -3,25 +3,33 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/verquest.svg)](https://badge.fury.io/rb/verquest)
4
4
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
5
5
 
6
- Verquest is a Ruby gem that offers an elegant solution for versioning API requests. It simplifies the process of defining and evolving your API schema over time, with robust support for:
6
+ Verquest describes your API's requests (bodies, query strings and path parameters) with a small Ruby DSL, one definition per API version. From that definition you get:
7
7
 
8
- - Defining versioned request structures
9
- - Gracefully handling API versioning
10
- - Mapping between external and internal parameter structures
11
- - Validating parameters against [JSON Schema](https://json-schema.org/)
12
- - Generating components for OpenAPI documentation
13
- - Mapping error keys back to the external API structure
8
+ - validation of incoming params against [JSON Schema](https://json-schema.org/), with errors that point at the invalid value (`/address/city`);
9
+ - mapping from your public API structure to the structure your app uses (`full_name` → `name`);
10
+ - JSON Schema components and OpenAPI parameters for your docs, ready for [rswag](https://github.com/rswag/rswag);
11
+ - version resolution: ask for `2025-07` and get the closest earlier definition, so unchanged requests need no new version.
12
+
13
+ It works in any Ruby app and has no Rails dependency. [Rails and rswag](docs/rails.md) shows how to plug it into controllers and request specs.
14
14
 
15
15
  Sponsored by [Stora](https://stora.co)
16
16
 
17
- > The gem is still in development. Until version 1.0, the API may change. There are some features like `oneOf`, `anyOf`, `allOf` that are not implemented yet. See open [issues](https://github.com/verquest/verquest/issues?q=sort:updated-desc%20is:issue%20is:open%20label:enhancement).
17
+ > Verquest is not 1.0 yet, so minor releases can change the API; the [changelog](CHANGELOG.md) lists every change. `anyOf` and `allOf` aren't in the DSL yet, see the open [issues](https://github.com/verquest/verquest/issues?q=sort:updated-desc%20is:issue%20is:open%20label:enhancement).
18
+
19
+ ## Contents
20
+
21
+ - [Installation](#installation)
22
+ - [Quick Start](#quick-start)
23
+ - [Core Features](#core-features): [schema definition](#schema-definition-and-validation), [oneOf](#oneof), [conditional schemas](#conditional-schemas), [custom field types](#custom-field-types), [versioning](#versioning), [mapping](#mapping-request-structure), [query parameters](#query-parameters), [path parameters](#path-parameters), [OpenAPI components](#component-generation-for-openapi)
24
+ - [Rails and rswag](docs/rails.md)
25
+ - [Configuration](#configuration)
18
26
 
19
27
  ## Installation
20
28
 
21
29
  Add this line to your application's Gemfile:
22
30
 
23
31
  ```ruby
24
- gem "verquest", "~> 0.6"
32
+ gem "verquest", "~> 0.7"
25
33
  ```
26
34
 
27
35
  And then execute:
@@ -32,9 +40,10 @@ bundle install
32
40
 
33
41
  ## Quick Start
34
42
 
35
- ### Define a versioned API requests
43
+ ### Define a request
44
+
45
+ A request class inherits from `Verquest::Base` and describes each version in a `version` block. An address, reused below:
36
46
 
37
- Address Create Request
38
47
  ```ruby
39
48
  class AddressCreateRequest < Verquest::Base
40
49
  description "Address Create Request"
@@ -49,9 +58,10 @@ class AddressCreateRequest < Verquest::Base
49
58
  end
50
59
  end
51
60
  end
52
- ````
61
+ ```
62
+
63
+ A user request that uses the address:
53
64
 
54
- User Create Request that uses the `AddressCreateRequest`
55
65
  ```ruby
56
66
  class UserCreateRequest < Verquest::Base
57
67
  description "User Create Request"
@@ -97,31 +107,32 @@ class UserCreateRequest < Verquest::Base
97
107
  end
98
108
  ```
99
109
 
100
- ### Example usage in Rails Controller
110
+ ### Process params in a Rails controller
101
111
 
102
- ```ruby
112
+ `process` validates the params and returns them mapped to your internal structure. It takes `ActionController::Parameters` as they are, so it replaces strong parameters:
103
113
 
114
+ ```ruby
104
115
  class UsersController < ApplicationController
105
- rescue_from Verquest::InvalidParamsError, with: :handle_invalid_params
106
-
107
- def create
108
- result = Users::Create.call(params: user_params) # service object to handle the creation logic
109
-
110
- if result.success?
111
- # render success response
112
- else
113
- # render error response
114
- end
116
+ rescue_from Verquest::InvalidParamsError do |error|
117
+ render json: {errors: error.errors}, status: :unprocessable_content
115
118
  end
116
119
 
117
- private
118
-
119
- def user_params
120
- UserCreateRequest.process(params, version: params[:api_version])
120
+ def create
121
+ user = Users::Create.call(params: UserCreateRequest.process(params, version: params[:api_version]))
122
+ render json: user, status: :created
121
123
  end
122
124
  end
123
125
  ```
124
126
 
127
+ Each error points at the invalid value:
128
+
129
+ ```ruby
130
+ error.errors
131
+ # => [{pointer: "/email", type: "format", message: "value at `/email` does not match format: email", details: nil}]
132
+ ```
133
+
134
+ Set `config.current_version` to drop the `version:` argument, or `config.validation_error_handling = :result` to get a `Verquest::Result` instead of an exception (see [Configuration](#configuration)). For query and path parameters in controllers, see [Rails and rswag](docs/rails.md).
135
+
125
136
  ### JSON schema for OpenAPI
126
137
  You can generate JSON Schema for your versioned requests, which can be used for API documentation:
127
138
 
@@ -268,19 +279,22 @@ The JSON schema can be used for both validation of incoming parameters and for g
268
279
 
269
280
  #### Component types
270
281
 
271
- - `field`: Represents a scalar value (string, integer, boolean, etc.).
272
- - `enum`: Represents a property with a limited set of values (enumeration).
273
- - `object`: Represents a JSON object with properties.
274
- - `array`: Represents a JSON array with scalar items.
275
- - `collection`: Represents a array of objects defined manually or by a reference to another request.
276
- - `reference`: Represents a reference to another request, allowing you to reuse existing request structures.
277
- - `const`: Represents a [constant](https://json-schema.org/understanding-json-schema/reference/const#constant-values) value that is always present in the request.
282
+ - `field`: a scalar value (string, integer, boolean, etc.).
283
+ - `enum`: a value from a fixed list.
284
+ - `object`: a JSON object with its own properties.
285
+ - `array`: a JSON array of scalar items.
286
+ - `collection`: a JSON array of objects, defined inline or with `item:` pointing at another request.
287
+ - `reference`: another request reused in place, or one of its properties with `property:`.
288
+ - `const`: a [constant](https://json-schema.org/understanding-json-schema/reference/const#constant-values) value.
289
+ - `one_of`: exactly one of several request shapes, see [oneOf](#oneof).
278
290
 
279
291
  #### Helper methods
280
292
 
281
- - `description`: Adds a description to the request or per version.
282
- - `schema_options`: Allows you to set additional options for the JSON Schema, such as `additional_properties` for request or per version. All fields (except `reference`) can be defined with options like `required`, `format`, `min_lenght`, `max_length`, etc. all in snake case.
283
- - `with_options`: Allows you to define multiple fields with the same options, reducing repetition.
293
+ - `description`: a description for the request, or for one version.
294
+ - `schema_options`: extra JSON Schema keywords for the request or one version, such as `additional_properties: false`.
295
+ - `with_options`: shared options for every property in the block.
296
+
297
+ Properties (except `reference`) take JSON Schema keywords in snake case, such as `format`, `min_length` or `max_length`, and Verquest writes them in camel case (`minLength`).
284
298
 
285
299
  #### Required properties
286
300
 
@@ -396,6 +410,166 @@ Will produce this validation schema:
396
410
  }
397
411
  ```
398
412
 
413
+ #### Conditional schemas
414
+
415
+ Use `condition` when a request changes shape depending on the payload. It compiles to JSON Schema `if` / `then` / `else` and works in a version, an `object`, and an inline `collection`:
416
+
417
+ ```ruby
418
+ version "2025-09" do
419
+ field :effective_from, type: :string, required: true
420
+ field :credit_card, type: :string
421
+ field :billing_address, type: :string
422
+
423
+ condition :pricing do
424
+ given { present :percentages }
425
+ then_apply do
426
+ object :percentages, required: true, min_properties: 1 do
427
+ field :unit_types, type: :number, maximum: 9999.99
428
+ end
429
+ end
430
+ else_apply do
431
+ field :percentage, type: :number, required: true, deprecated: true, not: {const: 0}
432
+ object :applies_to, deprecated: true do
433
+ field :unit_types, type: :boolean, map: "/apply_to_unit_types"
434
+ end
435
+ end
436
+ end
437
+
438
+ depends_on :credit_card do # condition { given { present :credit_card }; then_apply { ... } }
439
+ present :billing_address
440
+ end
441
+ end
442
+ ```
443
+
444
+ - `given` (the `if`) holds rules only. `then_apply` and `else_apply` hold rules and property declarations. A condition needs `given` and at least one branch.
445
+ - Rules: `present :a, :b`, `absent :a`, `equals :kind, "company"`, `value_in :kind, %w[a b]`, `satisfies :age, minimum: 18`, `any { ... }` and `none { ... }`. Rules in one block must all hold. Inside `given`, `equals`, `value_in` and `satisfies` also require their property.
446
+ - Branch properties are listed in the scope's `properties`, so `additionalProperties: false` keeps working and the mapping is the flat union. Each branch gets only rules: its required names, and `false` for properties that only the other branch declares. A missing branch is generated.
447
+ - Several conditions on one scope go into `allOf`, one entry each.
448
+ - `exclude_conditions :pricing` removes an inherited named condition in a later version. A named condition can be declared only once per scope, so exclude an inherited one before redeclaring it.
449
+ - `exclude_properties` only sees regular properties. To drop a branch property, exclude its condition.
450
+ - Conditions need `json_schema_version` `:draft7` or later, or `:openapi31`. Other versions ignore `if` / `then` / `else`, so Verquest raises `ArgumentError` when the version is prepared.
451
+
452
+ The example gives this schema:
453
+
454
+ ```json
455
+ {
456
+ "type": "object",
457
+ "required": ["effective_from"],
458
+ "properties": {
459
+ "effective_from": {"type": "string"}, "credit_card": {"type": "string"}, "billing_address": {"type": "string"},
460
+ "percentages": {"...": "..."}, "percentage": {"...": "..."}, "applies_to": {"...": "..."}
461
+ },
462
+ "additionalProperties": false,
463
+ "allOf": [
464
+ {
465
+ "if": {"required": ["percentages"]},
466
+ "then": {"required": ["percentages"], "properties": {"percentage": false, "applies_to": false}},
467
+ "else": {"required": ["percentage"], "properties": {"percentages": false}}
468
+ },
469
+ {"if": {"required": ["credit_card"]}, "then": {"required": ["billing_address"]}}
470
+ ]
471
+ }
472
+ ```
473
+
474
+ ##### Documenting a condition as a oneOf
475
+
476
+ `document_as: :one_of` keeps `if` / `then` / `else` in the validation schema, but `to_schema` (the documentation) renders a `oneOf` with one variant per branch. `then_apply` and `else_apply` take a `title`, `description` and `deprecated` for their variant:
477
+
478
+ ```ruby
479
+ version "2025-09" do
480
+ field :effective_from, type: :string, required: true
481
+
482
+ condition :pricing, document_as: :one_of do
483
+ given { present :percentages }
484
+ then_apply title: "Per-type percentages" do
485
+ object :percentages, required: true, min_properties: 1 do
486
+ field :unit_types, type: :number, maximum: 9999.99
487
+ end
488
+ end
489
+ else_apply title: "Shared percentage", deprecated: true do
490
+ field :percentage, type: :number, required: true
491
+ end
492
+ end
493
+ end
494
+ ```
495
+
496
+ ```json
497
+ {
498
+ "oneOf": [
499
+ {"type": "object", "title": "Per-type percentages", "required": ["effective_from", "percentages"], "properties": {"effective_from": {"...": "..."}, "percentages": {"...": "..."}}, "additionalProperties": false},
500
+ {"type": "object", "title": "Shared percentage", "deprecated": true, "required": ["effective_from", "percentage"], "properties": {"effective_from": {"...": "..."}, "percentage": {"...": "..."}}, "additionalProperties": false}
501
+ ]
502
+ }
503
+ ```
504
+
505
+ - Each variant lists the version's properties without the ones only the other branch declares, and adds its branch's rules. The `then` variant also holds the `given` rules, and the `else` variant `not` the `given` rules, unless it already forbids a property `given` requires. Both schemas accept the same payloads. When a rule clashes with a property's own schema (for example `given { absent :note }` against a declared `note`, or `satisfies :age, minimum: 18` against `minimum: 0`), the variant keeps both by adding the rule under `allOf`.
506
+ - Validation still uses the `if` / `then` / `else` schema, so validation errors point at those rules, not at a `oneOf` variant.
507
+ - When `additionalProperties` isn't `false`, the other branch's properties are forbidden with `false` in each variant.
508
+ - Only for a condition in a version, and it must be the only condition in that version. The version's `description` stays at the root of the schema.
509
+ - The schema has no root `properties`, so `to_ref(property:)` can't point into it.
510
+
511
+ #### oneOf
512
+
513
+ `one_of` accepts exactly one of several shapes. Each variant is a `reference` to another request, and `discriminator:` names the property that tells them apart:
514
+
515
+ ```ruby
516
+ class CardPaymentComponent < Verquest::Base
517
+ version "2025-06" do
518
+ const :method, value: "card", required: true
519
+ field :card_number, type: :string, required: true
520
+ field :expiry, type: :string, required: true
521
+ end
522
+ end
523
+
524
+ class OrderRequest < Verquest::Base
525
+ version "2025-06" do
526
+ field :order_id, type: :string, required: true
527
+
528
+ one_of name: :payment, discriminator: :method, required: true do
529
+ reference :card, from: CardPaymentComponent
530
+ reference :bank, from: BankPaymentComponent
531
+ end
532
+ end
533
+ end
534
+
535
+ OrderRequest.process({"order_id" => "o1", "payment" => {"method" => "card", "card_number" => "4242", "expiry" => "12/30"}})
536
+ # => {"order_id" => "o1", "payment" => {"method" => "card", "card_number" => "4242", "expiry" => "12/30"}}
537
+ ```
538
+
539
+ `to_schema` documents it as an OpenAPI `oneOf` with a `discriminator` mapping to the variants' components:
540
+
541
+ ```ruby
542
+ {"oneOf" => [{"$ref" => "#/components/schemas/CardPaymentComponent"}, {"$ref" => "#/components/schemas/BankPaymentComponent"}],
543
+ "discriminator" => {"propertyName" => "method", "mapping" => {"card" => "#/components/schemas/CardPaymentComponent", "bank" => "#/components/schemas/BankPaymentComponent"}}}
544
+ ```
545
+
546
+ - Without `name:`, the whole request is one of the variants (`one_of discriminator: :type do ... end` as the only property of a version).
547
+ - Without `discriminator:`, the value must match exactly one variant's schema. Validation rejects it otherwise; with validation off, mapping raises `Verquest::MappingError`.
548
+ - `one_of` works inside objects, collections and other variants, and with `nullable: true` and `map:`.
549
+
550
+ #### oneOf discriminators in validation
551
+
552
+ A `one_of` with a `discriminator:` keeps the discriminator in the validation schema. Its variants move into local `definitions` that `oneOf` references with `$ref`. An id, written as both `$id` and `id` so every JSON Schema version reads it, makes those references resolve wherever the schema is embedded:
553
+
554
+ ```ruby
555
+ one_of name: :pet, discriminator: :type do
556
+ reference :dog, from: DogComponent
557
+ reference :cat, from: CatComponent
558
+ end
559
+ ```
560
+
561
+ ```json
562
+ {
563
+ "$id": "https://verquest.local/one_of/3f1c9a0b2d4e5f67",
564
+ "id": "https://verquest.local/one_of/3f1c9a0b2d4e5f67",
565
+ "definitions": {"dog": {"type": "object", "...": "..."}, "cat": {"type": "object", "...": "..."}},
566
+ "oneOf": [{"$ref": "#/definitions/dog"}, {"$ref": "#/definitions/cat"}],
567
+ "discriminator": {"propertyName": "type", "mapping": {"dog": "#/definitions/dog", "cat": "#/definitions/cat"}}
568
+ }
569
+ ```
570
+
571
+ With `json_schema_version = :openapi31` or `:openapi30`, the validator checks only the variant the discriminator selects, so validation errors come from that variant only. A value that isn't an object, or that has no known discriminator value, gets a single `discriminator` error. Other versions ignore `discriminator` and check every variant.
572
+
399
573
  #### Custom Field Types
400
574
 
401
575
  You can define custom field types that can be used in `field` and `array` in the configuration.
@@ -559,7 +733,7 @@ Will be transformed to:
559
733
  "address_city": "Springfield",
560
734
  "address_zip": "12345"
561
735
  }
562
- ````
736
+ ```
563
737
 
564
738
  What you can use:
565
739
  - `/` to reference the root of the request structure
@@ -585,9 +759,81 @@ Will produce the following mapping:
585
759
  }
586
760
  ```
587
761
 
588
- There are some limitations and the implementation can be improved, but it should works for most common use cases.
762
+ The mapping tests in [`test/verquest/base_test.rb`](test/verquest/base_test.rb) have more examples.
763
+
764
+ ### Query parameters
765
+
766
+ A request class with `location :query` describes query parameters. `process` converts the query strings to the types the schema expects before it validates and maps them, and `to_parameters` documents them as OpenAPI parameters.
767
+
768
+ ```ruby
769
+ class DealsIndexQuery < Verquest::Base
770
+ location :query # inherited, so it can be set once on a base class
589
771
 
590
- See the mapping test (in `test/verquest/base_test.rb`) for more examples of mapping.
772
+ version "2025-06" do
773
+ field :page, type: :integer, minimum: 1, default: 1
774
+ field :archived, type: :boolean
775
+ enum :sort_direction, values: %w[ASC DESC], default: "DESC"
776
+ array :status, type: :string, explode: false, item_schema_options: {enum: %w[open won lost]}
777
+
778
+ object :created do # sent as created[after]=...
779
+ field :after, type: :string, format: "date-time", map: "/created_after"
780
+ end
781
+ end
782
+ end
783
+
784
+ DealsIndexQuery.process(request.query_parameters, version: "2025-06")
785
+ # ?page=2&archived=true&status=open,won&created[after]=2025-01-01T00:00:00Z
786
+ # => {"page" => 2, "archived" => true, "sort_direction" => "DESC", "status" => ["open", "won"], "created_after" => "2025-01-01T00:00:00Z"}
787
+ ```
788
+
789
+ Conversion follows the validation schema:
790
+ - `integer` and `number` from digits (`"2"`, `"-1.5"`), `boolean` from `"true"` and `"false"`, `null` from an empty string when the schema allows it;
791
+ - arrays split on commas with `explode: false` (`status=open,won`), or come from repeated keys with `explode: true` (`status[]=open&status[]=won`; Rails keeps only the last value of a plain repeated key). `config.query_array_explode` sets the default (`true`);
792
+ - objects (`deepObject`, one level deep) convert each property.
793
+
794
+ A value that can't be converted is left as it is, so the validation reports it. Shapes a query string can't express (`one_of` properties, arrays of objects, objects inside objects) raise `Verquest::DefinitionError` when the version is defined.
795
+
796
+ `to_parameters` returns one parameter object per property, with symbol keys and the schema inlined, ready for rswag:
797
+
798
+ ```ruby
799
+ DealsIndexQuery.to_parameters(version: "2025-06").each { |p| parameter(p) }
800
+ # {name: "status", in: :query, required: false, style: :form, explode: false,
801
+ # schema: {type: "array", items: {type: "string", enum: ["open", "won", "lost"]}}}
802
+ ```
803
+
804
+ Named OpenAPI examples go on the parameter with `parameter_examples:`. They are left out of the JSON schemas, because JSON Schema `examples` is an array:
805
+
806
+ ```ruby
807
+ array :status, type: :string, explode: false, item_schema_options: {enum: %w[open won lost]},
808
+ parameter_examples: {single: {value: %w[open]}, multiple: {value: %w[open won]}}
809
+ # => {name: "status", ..., examples: {single: {value: ["open"]}, multiple: {value: ["open", "won"]}}, schema: {...}}
810
+ ```
811
+
812
+ A property reference to another query class reuses its definition, e.g. a shared `expand` parameter: `reference :expand, from: ExpandQuery, property: :expand`.
813
+
814
+ ### Path parameters
815
+
816
+ `location :path` works like `location :query`, but documents `in: :path` parameters. Path parameters must be required scalars; arrays, objects, one_of and optional properties raise `Verquest::DefinitionError`.
817
+
818
+ ```ruby
819
+ class DealPath < Verquest::Base
820
+ location :path
821
+
822
+ version "2025-06" do
823
+ field :id, type: :string, required: true, pattern: "^deal_", description: "The ID of the deal"
824
+ end
825
+ end
826
+
827
+ DealPath.process({"id" => params[:id]}) # raises Verquest::InvalidParamsError for "con_123"
828
+ DealPath.to_parameters(version: "2025-06")
829
+ # => [{name: "id", in: :path, required: true, description: "The ID of the deal", schema: {type: "string", pattern: "^deal_"}}]
830
+ ```
831
+
832
+ The parameter name comes from the property. When a route uses another name (`/deals/{deal_id}/win`), rename the key before `process` and the `name` of the parameter object, as the [Rails and rswag](docs/rails.md) example does.
833
+
834
+ ### Rails and rswag
835
+
836
+ See [Rails and rswag](docs/rails.md) for a controller concern that processes body, query and path classes, and an rswag shared context that documents them.
591
837
 
592
838
  ### Component Generation for OpenAPI
593
839
 
@@ -621,20 +867,28 @@ Verquest.configure do |config|
621
867
  config.remove_extra_root_keys = true # default
622
868
 
623
869
  # Set custom version resolver
624
- config.version_resolver = CustomeVersionResolver # default is `Verquest::VersionResolver`
870
+ config.version_resolver = CustomVersionResolver # default is `Verquest::VersionResolver`
625
871
 
626
872
  # Set default value for additional properties
627
873
  config.default_additional_properties = false # default
874
+
875
+ # Default explode value of query arrays (true: status[]=a&status[]=b, false: status=a,b)
876
+ config.query_array_explode = true # default
877
+
878
+ # Reusable field types, see Custom Field Types
879
+ config.custom_field_types = {} # default
628
880
  end
629
881
  ```
630
882
 
631
883
  ## Documentation
632
884
 
633
- For detailed documentation, please visit the [YARD documentation](https://www.rubydoc.info/gems/verquest/0.6.0/).
885
+ The API reference is on [RubyDoc](https://www.rubydoc.info/gems/verquest). Guides:
886
+
887
+ - [Rails and rswag](docs/rails.md): controllers and request specs for body, query and path parameters.
634
888
 
635
889
  ## Development
636
890
 
637
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
891
+ After checking out the repo, run `bin/setup` to install dependencies. `bundle exec rake` runs the tests, RuboCop and the YARD documentation check; `COVERAGE=1 bundle exec rake` adds a coverage report. `bin/console` opens an interactive prompt with the gem loaded.
638
892
 
639
893
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `gem_version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
640
894
 
data/Rakefile CHANGED
@@ -32,4 +32,9 @@ task :yard do
32
32
  puts "\nYARD documentation: 100% documented"
33
33
  end
34
34
 
35
+ desc "Benchmark the transformer (objects allocated and calls per second)"
36
+ task :bench do
37
+ ruby "-Ilib", "bench/transformer.rb"
38
+ end
39
+
35
40
  task default: %i[test rubocop yard]