versponse 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +15 -6
- data/CHANGELOG.md +59 -1
- data/CODE_OF_CONDUCT.md +1 -1
- data/LICENSE.txt +1 -1
- data/README.md +674 -20
- data/Rakefile +26 -3
- data/lib/versponse/base/class_methods.rb +180 -0
- data/lib/versponse/base/dsl.rb +231 -0
- data/lib/versponse/base.rb +35 -0
- data/lib/versponse/builder.rb +423 -0
- data/lib/versponse/cache.rb +47 -0
- data/lib/versponse/callable.rb +74 -0
- data/lib/versponse/class_policies.rb +15 -0
- data/lib/versponse/collapse_if_plan.rb +26 -0
- data/lib/versponse/collections.rb +49 -0
- data/lib/versponse/components.rb +37 -0
- data/lib/versponse/condition.rb +33 -0
- data/lib/versponse/configuration.rb +261 -0
- data/lib/versponse/const_as_enum.rb +39 -0
- data/lib/versponse/context_override/call.rb +27 -0
- data/lib/versponse/context_override/merge.rb +23 -0
- data/lib/versponse/context_override.rb +25 -0
- data/lib/versponse/context_plan.rb +24 -0
- data/lib/versponse/deep_copy.rb +27 -0
- data/lib/versponse/deep_freeze.rb +20 -0
- data/lib/versponse/discriminated_plan.rb +26 -0
- data/lib/versponse/error_policy/recovery.rb +68 -0
- data/lib/versponse/error_policy.rb +97 -0
- data/lib/versponse/expansion.rb +46 -0
- data/lib/versponse/extractors/block.rb +24 -0
- data/lib/versponse/extractors/fallback.rb +27 -0
- data/lib/versponse/extractors/itself.rb +21 -0
- data/lib/versponse/extractors/key.rb +28 -0
- data/lib/versponse/extractors/path.rb +28 -0
- data/lib/versponse/extractors.rb +23 -0
- data/lib/versponse/field_order.rb +23 -0
- data/lib/versponse/field_type.rb +88 -0
- data/lib/versponse/formatters.rb +38 -0
- data/lib/versponse/gem_version.rb +6 -0
- data/lib/versponse/inline_validation/builder.rb +129 -0
- data/lib/versponse/inline_validation/state.rb +16 -0
- data/lib/versponse/inline_validation.rb +86 -0
- data/lib/versponse/internals.rb +553 -0
- data/lib/versponse/json_value.rb +22 -0
- data/lib/versponse/lazy_plan.rb +30 -0
- data/lib/versponse/naming.rb +68 -0
- data/lib/versponse/object_schema.rb +25 -0
- data/lib/versponse/open_api/builder.rb +159 -0
- data/lib/versponse/open_api/component_set.rb +33 -0
- data/lib/versponse/open_api/declarations.rb +90 -0
- data/lib/versponse/open_api/document.rb +187 -0
- data/lib/versponse/open_api/ref_check.rb +89 -0
- data/lib/versponse/open_api/rswag_specs.rb +98 -0
- data/lib/versponse/open_api/sources.rb +72 -0
- data/lib/versponse/open_api/values.rb +29 -0
- data/lib/versponse/open_api.rb +12 -0
- data/lib/versponse/output_validator.rb +82 -0
- data/lib/versponse/pattern.rb +38 -0
- data/lib/versponse/plan.rb +104 -0
- data/lib/versponse/properties/array.rb +56 -0
- data/lib/versponse/properties/base.rb +325 -0
- data/lib/versponse/properties/collection.rb +135 -0
- data/lib/versponse/properties/const.rb +46 -0
- data/lib/versponse/properties/container.rb +51 -0
- data/lib/versponse/properties/enum.rb +93 -0
- data/lib/versponse/properties/field.rb +43 -0
- data/lib/versponse/properties/item_one_of.rb +19 -0
- data/lib/versponse/properties/object.rb +32 -0
- data/lib/versponse/properties/one_of.rb +176 -0
- data/lib/versponse/properties/reference.rb +133 -0
- data/lib/versponse/references.rb +162 -0
- data/lib/versponse/registry.rb +124 -0
- data/lib/versponse/schema_context.rb +69 -0
- data/lib/versponse/schema_merge.rb +24 -0
- data/lib/versponse/selection/scope.rb +36 -0
- data/lib/versponse/selection.rb +92 -0
- data/lib/versponse/selection_schema.rb +101 -0
- data/lib/versponse/steps/checked.rb +43 -0
- data/lib/versponse/steps/checked_read.rb +46 -0
- data/lib/versponse/steps/checked_read_formatted.rb +32 -0
- data/lib/versponse/steps/checked_value.rb +48 -0
- data/lib/versponse/steps/const.rb +44 -0
- data/lib/versponse/steps/nested.rb +98 -0
- data/lib/versponse/steps/one_of.rb +62 -0
- data/lib/versponse/steps/read.rb +48 -0
- data/lib/versponse/steps/read_formatted.rb +28 -0
- data/lib/versponse/steps/value.rb +52 -0
- data/lib/versponse/supported_versions.rb +61 -0
- data/lib/versponse/validator/all.rb +32 -0
- data/lib/versponse/validator/array_check.rb +48 -0
- data/lib/versponse/validator/boolean_check.rb +16 -0
- data/lib/versponse/validator/both.rb +24 -0
- data/lib/versponse/validator/class_check.rb +25 -0
- data/lib/versponse/validator/closed_object_check.rb +78 -0
- data/lib/versponse/validator/combinator.rb +40 -0
- data/lib/versponse/validator/compiler.rb +146 -0
- data/lib/versponse/validator/constant.rb +33 -0
- data/lib/versponse/validator/date_time_check.rb +21 -0
- data/lib/versponse/validator/format_check.rb +49 -0
- data/lib/versponse/validator/integer_check.rb +16 -0
- data/lib/versponse/validator/nullable_check.rb +22 -0
- data/lib/versponse/validator/number_check.rb +58 -0
- data/lib/versponse/validator/object_check.rb +90 -0
- data/lib/versponse/validator/ref.rb +38 -0
- data/lib/versponse/validator/root.rb +31 -0
- data/lib/versponse/validator/string_check.rb +48 -0
- data/lib/versponse/validator/string_keys.rb +21 -0
- data/lib/versponse/validator/type_check.rb +84 -0
- data/lib/versponse/validator/value_check.rb +36 -0
- data/lib/versponse/validator.rb +10 -0
- data/lib/versponse/variant_dispatcher.rb +35 -0
- data/lib/versponse/version.rb +179 -1
- data/lib/versponse/version_resolver.rb +27 -0
- data/lib/versponse/versions.rb +116 -0
- data/lib/versponse/view.rb +126 -0
- data/lib/versponse/wrapper.rb +53 -0
- data/lib/versponse.rb +165 -3
- metadata +151 -12
- data/sig/versponse.rbs +0 -4
data/README.md
CHANGED
|
@@ -1,43 +1,697 @@
|
|
|
1
1
|
# Versponse
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/versponse)
|
|
4
|
+
[](https://github.com/verquest/versponse/actions/workflows/main.yml)
|
|
5
|
+
[](LICENSE.txt)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
Versponse describes your API's responses with a small Ruby DSL, one definition per API version, with blueprinter-style views. From that definition you get:
|
|
8
|
+
|
|
9
|
+
- Rendering of objects and hashes to JSON-ready hashes or JSON strings.
|
|
10
|
+
- An OpenAPI schema for every response, version, and view, built from the same definition that renders the output.
|
|
11
|
+
- Optional validation of every rendered response against its own schema, for tests and development.
|
|
12
|
+
- Version resolution. A request for `2026-01` gets the closest earlier definition, so an unchanged response needs no new version.
|
|
13
|
+
|
|
14
|
+
The DSL is shared with [verquest](https://github.com/verquest/verquest), which describes requests, and the two gems can reference each other's schemas. See [differences from verquest's DSL](#differences-from-verquests-dsl).
|
|
15
|
+
|
|
16
|
+
Sponsored by [Stora](https://stora.co)
|
|
17
|
+
|
|
18
|
+
> Versponse is not 1.0 yet, so minor releases can change the API. The [changelog](CHANGELOG.md) lists every change.
|
|
19
|
+
|
|
20
|
+
## Contents
|
|
21
|
+
|
|
22
|
+
- [Installation](#installation)
|
|
23
|
+
- [Quick start](#quick-start)
|
|
24
|
+
- [Properties](#properties)
|
|
25
|
+
- [Versions](#versions)
|
|
26
|
+
- [Views](#views)
|
|
27
|
+
- [Expansion](#expansion)
|
|
28
|
+
- [Field selection](#field-selection)
|
|
29
|
+
- [Schemas](#schemas)
|
|
30
|
+
- [OpenAPI documents and rswag](#openapi-documents-and-rswag)
|
|
31
|
+
- [Output validation](#output-validation)
|
|
32
|
+
- [Nil and errors](#nil-and-errors)
|
|
33
|
+
- [Sharing references with verquest](#sharing-references-with-verquest)
|
|
34
|
+
- [Formats and validation notes](#formats-and-validation-notes)
|
|
35
|
+
- [Configuration](#configuration)
|
|
36
|
+
- [Rails](#rails)
|
|
37
|
+
- [Performance](#performance)
|
|
6
38
|
|
|
7
39
|
## Installation
|
|
8
40
|
|
|
9
|
-
|
|
41
|
+
```ruby
|
|
42
|
+
gem "versponse"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Versponse needs Ruby 3.2 or later. [verquest](https://github.com/verquest/verquest) is optional: install it to reference request classes and to list them with `requests namespace:`.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class UnitTypeResponse < Versponse::Base
|
|
51
|
+
description "A unit type"
|
|
52
|
+
|
|
53
|
+
version "2025-09" do
|
|
54
|
+
const :object, value: "unit_type"
|
|
55
|
+
field :id, type: :string, map: "public_id"
|
|
56
|
+
field :name, type: :string
|
|
57
|
+
field :stripe_product_id, type: :string, map: "billing_id", nullable: true
|
|
58
|
+
enum :status, type: :string, values: %w[active archived]
|
|
59
|
+
array :selling_points, type: :string
|
|
60
|
+
field :created_at, type: :string, format: "date-time"
|
|
61
|
+
reference :site, from: SiteResponse
|
|
62
|
+
reference :security_deposit, from: MoneyResponse, nullable: true
|
|
63
|
+
collection :prices, item: PriceResponse do |unit_type|
|
|
64
|
+
unit_type.prices.reject { |price| price.amount.nil? }
|
|
65
|
+
end
|
|
10
66
|
|
|
11
|
-
|
|
67
|
+
view :embedded do
|
|
68
|
+
exclude_properties :site
|
|
69
|
+
end
|
|
12
70
|
|
|
13
|
-
|
|
14
|
-
|
|
71
|
+
view :embedded_without_prices do
|
|
72
|
+
include_view :embedded
|
|
73
|
+
exclude_properties :prices
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
version "2026-03" do
|
|
78
|
+
exclude_properties :name
|
|
79
|
+
field :title, type: :string, map: "name"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
UnitTypeResponse.serialize(unit_type, version: "2025-09")
|
|
84
|
+
# => {
|
|
85
|
+
# "object" => "unit_type", "id" => "ut_1", "name" => "Small", "stripe_product_id" => "prod_1",
|
|
86
|
+
# "status" => "active", "selling_points" => ["Dry"], "created_at" => "2025-09-01T10:00:00.000Z",
|
|
87
|
+
# "site" => {"id" => "site_1", "name" => "Leeds", "address" => {"city" => "Leeds", "postcode" => nil}},
|
|
88
|
+
# "security_deposit" => nil,
|
|
89
|
+
# "prices" => [{"id" => "price_1", "amount" => {"amount" => 5000, "currency" => "GBP"}, "interval" => "month"}]
|
|
90
|
+
# }
|
|
15
91
|
```
|
|
16
92
|
|
|
17
|
-
|
|
93
|
+
`serialize` takes an object, a Hash with Symbol or String keys, a Set, or anything that responds to `to_ary`, such as an Array or an ActiveRecord relation. It returns a Hash with String keys. `serialize_json` takes the same arguments and returns a String.
|
|
94
|
+
|
|
95
|
+
Output is yours to change: arrays are new Arrays, and Hash or Array `default:` and `const` values are copies. A `field type: :object` value is the source's own object.
|
|
18
96
|
|
|
19
|
-
|
|
20
|
-
|
|
97
|
+
The value of a `collection` or `array` property must be a Set or respond to `to_ary`. Any other value raises `Versponse::ExtractionError`.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
UnitTypeResponse.serialize(unit_types, version: "2025-09", view: :embedded, root: "unit_types", meta: {"request_id" => "r1"})
|
|
101
|
+
# => {"unit_types" => [...], "meta" => {"request_id" => "r1"}}
|
|
21
102
|
```
|
|
22
103
|
|
|
23
|
-
##
|
|
104
|
+
## Properties
|
|
24
105
|
|
|
25
|
-
|
|
106
|
+
| Method | What it renders |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `field` | A scalar: `string`, `number`, `integer`, `boolean`, `object` for a free-form map, or a union such as `[:integer, :string]` |
|
|
109
|
+
| `enum` | A value from a fixed list (`values:`, optional `type:`) |
|
|
110
|
+
| `const` | A constant (`value:`) |
|
|
111
|
+
| `array` | An array of scalars (`type:`, `item_schema_options:`) |
|
|
112
|
+
| `object` | An inline object whose block declares its properties |
|
|
113
|
+
| `collection` | An array of objects: of another response class (`item:`) or inline (block) |
|
|
114
|
+
| `reference` | Another response class in place (`from:`, `view:`) |
|
|
115
|
+
| `one_of` | One of several response classes, picked with `match:` |
|
|
26
116
|
|
|
27
|
-
|
|
117
|
+
Options shared by every property:
|
|
28
118
|
|
|
29
|
-
|
|
119
|
+
- `map:` reads the value from another method or key. A path reads through nested objects (`map: "address/city"`). `"."` means the same object, so an inline `object` can group fields of its parent.
|
|
120
|
+
- A block computes the value. It gets the object and the `context:` passed to `serialize`:
|
|
121
|
+
```ruby
|
|
122
|
+
field :secret, type: :string, nullable: true do |endpoint, context|
|
|
123
|
+
context[:show_secret] ? endpoint.secret : nil
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
- `if:` and `unless:` take `->(object)` or `->(object, context)` and emit the key only when the condition holds.
|
|
127
|
+
- `context:` on `reference`, `collection`, `object` and `one_of` gives the property's subtree its own context. A Hash is merged over the parent context, and a callable `->(object, context)` returns the child context (a Hash):
|
|
30
128
|
|
|
31
|
-
|
|
129
|
+
```ruby
|
|
130
|
+
reference :site, from: SiteResponse, context: {embedded: true}
|
|
32
131
|
|
|
33
|
-
|
|
132
|
+
collection :line_items, item: LineItemResponse,
|
|
133
|
+
context: ->(invoice, context) { context.merge(currency: invoice.currency) }
|
|
134
|
+
```
|
|
34
135
|
|
|
35
|
-
|
|
136
|
+
It applies to everything below the property (full and collapsed shapes, `collapse_if`, blocks and conditions), while the parent and its other properties keep theirs. The callable runs once per value: once for a whole collection, and not at all for `nil` or a property its condition leaves out. A `one_of` variant takes `context:` too, applied after the `one_of`'s, and its callable gets the matched value. `reference property:` carries the reused property's `context:` over unless it declares its own, and `with_options context:` applies it to the associations in the block. A `reference` or `collection item:` of a non-Versponse class, and the unnamed `one_of` of a collection, reject `context:`.
|
|
137
|
+
- `nullable: true` allows `null`.
|
|
138
|
+
- `default:` on `field`, `enum`, and `array` replaces a `nil` value. It takes a value, `->(object)`, or `->(object, context)`. A value is written to schemas as `default` and must match the property's schema. A Time default must be a callable, and is formatted like any value. `default: nil` means no default. `reference property:` keeps the reused property's `default:`. Other properties raise `Versponse::DefinitionError` for `default:`, and `with_options default:` skips them.
|
|
139
|
+
- `type:` on `field` and `array` takes a union of two or more built-in types. `field :notice_period, type: [:integer, :string], nullable: true` writes `"type": ["integer", "string", "null"]`. Under `json_schema_version = :openapi30` it writes an `anyOf` of single types, each with `nullable: true`, because 3.0's `nullable` applies only next to a `type`. `format:` and its formatter still apply.
|
|
140
|
+
- A union can be narrowed with `any_of:`, as in `field :notice_period, type: [:integer, :string], nullable: true, any_of: [{"type" => "integer"}, {"enum" => ["send_immediately"]}, {"type" => "null"}]`. Under OpenAPI 3.0 the two `anyOf`s are combined with `allOf`. `nullable:` adds `"null"` to `type` but never adds a null branch to your `any_of:`, so list `{"type" => "null"}` yourself when the field is nullable. Otherwise `nil` matches no alternative.
|
|
141
|
+
- A user-supplied `any_of:` is written verbatim, in documents and validation schemas alike. `{"type" => "null"}` isn't valid OpenAPI 3.0, so under `:openapi30` it ends up in the document as is, but leaving it out makes `nil` fail output validation. For a nullable union under OpenAPI 3.0, `enum:` with `nil` in its values avoids the problem.
|
|
142
|
+
- `enum` writes a `type` next to its values. Without `type:`, Versponse infers it from the values: Strings give `string`, Integers give `integer`, Integers with Floats give `number`, `true` and `false` give `boolean`, and mixed values give a union. Pass `type:` or `with_options type:` to use a custom type, of which only the base type is written, or to write `number` for whole numbers. Every value must be of the type, and a value with no JSON type, such as a Symbol, raises `Versponse::DefinitionError`. `nullable: true` adds `"null"` to `type` and `nil` to the values, or writes `nullable: true` under OpenAPI 3.0:
|
|
143
|
+
```ruby
|
|
144
|
+
enum :status, values: %w[active archived], nullable: true
|
|
145
|
+
# => {"type" => ["string", "null"], "enum" => ["active", "archived", nil]}
|
|
146
|
+
enum :notice_period, values: [7, 30, "send_immediately"]
|
|
147
|
+
# => {"type" => ["integer", "string"], "enum" => [7, 30, "send_immediately"]}
|
|
148
|
+
```
|
|
149
|
+
- Other options are JSON Schema keywords in snake case, such as `description:`, `format:` or `max_length:`. They're written in camel case (`maxLength`). OpenAPI extensions go in as written (`"x-internal": true`). Anything else raises `Versponse::DefinitionError`, with a suggestion for typos such as `nulable:`.
|
|
150
|
+
- `with_options type: :string, nullable: true do ... end` applies options to every property in the block, `view:` and `discriminator:` included. Explicit options win. As in verquest, a nested `with_options` replaces the outer options, `map:` is ignored, and each `version` block starts without defaults.
|
|
36
151
|
|
|
37
|
-
|
|
152
|
+
Versponse computes `required`. A property without `if:` or `unless:` is always emitted, so the schema lists it as required.
|
|
153
|
+
|
|
154
|
+
`one_of` picks a variant with `match:`. It takes a class, a lambda, or an array of them, and compares each with `===`:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
one_of :creator, discriminator: "object" do
|
|
158
|
+
source { |task, _context| task.created_by }
|
|
159
|
+
reference from: StaffResponse, match: [Staff, StaffMembership]
|
|
160
|
+
reference from: CustomerResponse, match: Customer
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`source { |object, context| }` computes the value of an inline `object`, an inline `collection` or a `one_of`, and can't be combined with `map:`. A `collection` with `item:` takes its computed value from its block instead, so `source` doesn't apply there.
|
|
165
|
+
|
|
166
|
+
### Polymorphic collections
|
|
167
|
+
|
|
168
|
+
An unnamed `one_of` inside a `collection` block types each item separately:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
collection :activity do
|
|
172
|
+
one_of discriminator: "object" do
|
|
173
|
+
reference from: OrderResponse, view: :embedded, match: Order
|
|
174
|
+
reference from: TenancyResponse, view: :embedded, match: Tenancy
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
An item that matches no variant raises `Versponse::ExtractionError` with its index (`...activity.3: no one_of variant matches Note`), and `nullable: true` on the item `one_of` allows `nil` items.
|
|
180
|
+
|
|
181
|
+
### Reusing a property
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
reference :billing_address, from: ContactResponse, property: :address, map: "billing"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
This renders as if `ContactResponse`'s `address` were declared here. A `map:` or block on the reference overrides the source. The referenced property's `nullable:` carries over, and so does its `context:` unless the reference declares its own. Its `if:` and `unless:` don't carry over.
|
|
188
|
+
|
|
189
|
+
## Versions
|
|
190
|
+
|
|
191
|
+
A new version starts as a copy of the previously declared one:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
class UserResponse < Versponse::Base
|
|
195
|
+
version "2025-01" do
|
|
196
|
+
field :id, type: :string
|
|
197
|
+
field :name, type: :string
|
|
198
|
+
field :email, type: :string
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
version "2025-06", exclude_properties: %i[email] do
|
|
202
|
+
field :name, type: :string, map: "email" # redeclaring replaces it in place
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
version "2025-09" do
|
|
206
|
+
exclude_properties :name
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
version "2026-01", inherit: false do # start empty
|
|
210
|
+
field :id, type: :string
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
version "2026-03", inherit: "2025-06" # copy the closest version at or before it (2025-01)
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
A request for any version resolves to the closest declared version at or before it, so `"2025-12"` uses `"2025-09"`. A request older than every version raises `Versponse::VersionNotFoundError`. Each referenced class resolves the requested version on its own, so a `MoneyResponse` with only `2025-01` serves every later request.
|
|
218
|
+
|
|
219
|
+
A class with versions can't be subclassed, which raises `Versponse::DefinitionError`. Share code through a base class without versions.
|
|
220
|
+
|
|
221
|
+
`supported_versions` limits the versions a class serves. Both bounds are inclusive and optional:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class OverlockResponse < Versponse::Base
|
|
225
|
+
supported_versions min: "2025-09", max: "2026-03"
|
|
226
|
+
|
|
227
|
+
version "2025-09" do
|
|
228
|
+
field :reason, type: :string
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
OverlockResponse.serialize(overlock, version: "2025-12") # uses 2025-09
|
|
233
|
+
OverlockResponse.serialize(overlock, version: "2026-06") # raises Versponse::VersionNotFoundError
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- `min` defaults to the first declared version. Set it when a class should appear later than its first `version` block. The range applies to the requested version, not to the blocks. With `supported_versions min: "preview"` and a single `version "2025-09"` block, the 2025-09 block serves `"preview"`, and `"2025-09"` raises. `max` defaults to no upper bound.
|
|
237
|
+
- A version outside the range raises `Versponse::VersionNotFoundError`, like a version older than every declared one. `serialize`, schemas, references, and `expandable_paths` raise it. `Versponse.components` and OpenAPI documents leave the class and its wrappers out of that version. `Versponse.eager_load!` compiles only the versions the range serves.
|
|
238
|
+
- `config.version_resolver` compares versions, so with the default resolver `"preview"` sorts after dated versions. Before caching, Versponse maps a request to the closest earlier registered name. The registered names are every version any class declares, plus every `supported_versions` bound. As a result, `min: "preview"` works without any class declaring `"preview"`, and a request after a `max` stays outside the range. Errors name the requested version.
|
|
239
|
+
- `min` after `max` raises `Versponse::DefinitionError` when declared. A `max` before the first declared version would leave nothing to serve, so it raises `Versponse::DefinitionError` the first time the class resolves a version, and from `eager_load!`.
|
|
240
|
+
- Declare it on the class before the first `version` block. Subclasses of an abstract base class inherit the range and can declare their own. `supported_versions` with no arguments removes an inherited range.
|
|
241
|
+
|
|
242
|
+
Set `current_version` to drop the `version:` argument:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
Versponse.configure { |config| config.current_version = -> { Current.api_version } }
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Time precision
|
|
249
|
+
|
|
250
|
+
`time_precision` sets the fraction digits of `date-time` values, from 0 (whole seconds) to 9. With `from:`, it applies from that version on, so an API can change precision in a new version:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
class BaseResponse < Versponse::Base
|
|
254
|
+
time_precision 0 # 2025-09-01T10:00:00Z
|
|
255
|
+
time_precision 3, from: "2026-03" # 2025-09-01T10:00:00.000Z
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- A request uses the setting with the closest `from:` at or before it, compared with `config.version_resolver`. If no `from:` matches, it uses the setting without `from:`. Without either, `config.formatters` applies, which writes milliseconds by default.
|
|
260
|
+
- Each `from:` is registered like a `supported_versions` bound, so it takes effect even when no class declares that version.
|
|
261
|
+
- It replaces the `date-time` formatter of `config.formatters` for the class's fields, arrays, inline objects, and `reference property:`. A nested class (`reference from:`, `collection item:`, or a `one_of` variant) renders with its own setting, so declare `time_precision` on a shared base class.
|
|
262
|
+
- Declare it on the class before the first `version` block. A subclass without `time_precision` inherits its closest ancestor's settings. A subclass that sets any uses only its own, so settings from two classes never mix. Schemas don't change.
|
|
263
|
+
|
|
264
|
+
## Views
|
|
265
|
+
|
|
266
|
+
Views live inside a version. Every view starts from all of the version's properties:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
class ProductResponse < Versponse::Base
|
|
270
|
+
version "2025-09" do
|
|
271
|
+
field :id, type: :string
|
|
272
|
+
field :name, type: :string
|
|
273
|
+
field :price, type: :integer
|
|
274
|
+
|
|
275
|
+
view :embedded do
|
|
276
|
+
exclude_properties :price
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
view :admin do
|
|
280
|
+
include_view :embedded # applies the embedded view's operations
|
|
281
|
+
field :secret, type: :string # exists only in this view
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
version "2026-01" do # inherits both views
|
|
286
|
+
exclude_properties :name
|
|
287
|
+
field :title, type: :string, map: "name"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
ProductResponse.serialize(product, version: "2025-09", view: :admin)
|
|
292
|
+
# => {"id" => "p1", "name" => "Box", "secret" => "x"}
|
|
293
|
+
ProductResponse.serialize(product, version: "2026-01", view: :embedded)
|
|
294
|
+
# => {"id" => "p1", "title" => "Box"}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`reference` and `collection` take `view:` to render the nested class in one of its views. Use it to break cycles: an order embeds its tenancy in a view without the tenancy's order. Redeclaring a view in a later version replaces it. A property declared at version level replaces an inherited view's view-only property with the same key, so one line moves a view-only field into every view.
|
|
298
|
+
|
|
299
|
+
These mistakes raise `Versponse::DefinitionError` when the version block closes:
|
|
300
|
+
|
|
301
|
+
- Excluding an unknown property or an identifier. Redeclare the identifiers instead.
|
|
302
|
+
- Declaring a version or a view twice.
|
|
303
|
+
- An `inherit:` that names a version before every declared one.
|
|
304
|
+
- An `include_view` of an unknown view.
|
|
305
|
+
- Views that include each other in a cycle.
|
|
306
|
+
|
|
307
|
+
`VersionNotFoundError`, `ViewNotFoundError`, and `PropertyNotFoundError` are raised only for what a caller asks for at runtime: a version, a view, or `to_validation_schema(property:)`. You can map them to client errors.
|
|
308
|
+
|
|
309
|
+
## Expansion
|
|
310
|
+
|
|
311
|
+
Mark associations `expandable: true` and give the referenced class an `identifier`. Unless a request expands them, they render collapsed:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
class SiteResponse < Versponse::Base
|
|
315
|
+
version "2025-09" do
|
|
316
|
+
identifier :id, map: "public_id"
|
|
317
|
+
field :name, type: :string
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
class UnitResponse < Versponse::Base
|
|
322
|
+
version "2025-09" do
|
|
323
|
+
field :id, type: :string
|
|
324
|
+
reference :site, from: SiteResponse, expandable: true
|
|
325
|
+
collection :prices, item: PriceResponse, expandable: true
|
|
326
|
+
reference :deal, from: DealResponse # DealResponse has an expandable :stage
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
UnitResponse.serialize(unit, version: "2025-09")
|
|
331
|
+
# => {"id" => "u1", "site" => {"id" => "site_1"}, "prices" => [{"id" => "p1"}], "deal" => {"id" => "d1", "stage" => {"id" => "s1"}}}
|
|
332
|
+
UnitResponse.serialize(unit, version: "2025-09", expand: ["site", "deal.stage"])
|
|
333
|
+
# => site and deal.stage in full
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
- `expand:` takes dotted paths through any association, or `"*"` for every path `expandable_paths` lists. Use `"*"` for webhooks and internal renders. `"deal.*"` expands `deal` and every path below it. Under `"*"`, a recursive association expands once, so cyclic object graphs are safe. An explicit path through a recursive class goes as deep as you name it (`children.children`). Each class caches up to 256 validated `expand:` lists.
|
|
337
|
+
- An expandable `one_of` collapses each variant. With `discriminator:` the collapsed form keeps the discriminator const (`{"object" => "order", "id" => "o1"}`), but the OpenAPI `discriminator` keyword is left out, because a mapping would send collapsed payloads to the full schema.
|
|
338
|
+
- `identifier` declares the field in both shapes: the full one and the collapsed one. It takes `field` options except `if:` and `unless:`, and a property with the same key raises `Versponse::DefinitionError`. Redeclare the identifier to change it.
|
|
339
|
+
- `identifier :id, map: "public_id", collapse_if: ->(stage) { !stage.archived? }` renders the association in full when the condition is false, for objects with no identifier to show. The full shape then leaves the identifier out, so it isn't required in the full schema. A `collection item:` renders in full when any item fails. An expandable `one_of` decides for each value, so each item of a polymorphic collection decides for itself. Schemas don't change, because the `anyOf` already includes the full shape. The condition takes `(object)` or `(object, context)`. Only one identifier per version can declare it, and later versions inherit it until they redeclare their identifiers.
|
|
340
|
+
- Schemas describe both shapes as `anyOf: [{$ref Site}, {$ref SiteCollapsed}]`, and each class with an identifier gets a `<Name>Collapsed` component.
|
|
341
|
+
- `UnitResponse.expandable_paths(version: "2025-09")` lists every path (`["deal.stage", "prices", "site"]`), for scope checks. It lists each recursive association once.
|
|
342
|
+
- `UnitResponse.expandable_targets(version: "2025-09")` maps each of those paths to the classes it renders (`{"deal.stage" => [StageResponse], ...}`). A path renders a `reference`'s `from:`, a `collection`'s `item:`, or every `one_of` variant. Use it to derive per-path metadata from the classes, such as the scopes an expand needs. Versponse builds the map on the first call and caches it.
|
|
343
|
+
- An unknown path raises `Versponse::InvalidExpandError` (its `paths` lists them), unless `config.unknown_expand = :ignore`.
|
|
344
|
+
|
|
345
|
+
## Field selection
|
|
346
|
+
|
|
347
|
+
`fields:` renders a subset, with dotted paths like `expand:`:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
OrderResponse.serialize(order, fields: ["status", "total", "line_items.price"], expand: ["line_items"])
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
- `"line_items"` selects the whole association. `"line_items.price"` selects only `price` inside it, and implies the `line_items` key. `"line_items.*"` and `"*"` select everything at that level. `nil` or `[]` renders everything.
|
|
354
|
+
- Identifiers and `const` properties, including `one_of` discriminators, always render.
|
|
355
|
+
- Unselected properties never run their blocks, so their queries are skipped too.
|
|
356
|
+
- A path below an expandable association needs that association expanded. Otherwise, like an unknown path, it raises `Versponse::InvalidFieldsError` (`paths` lists them), unless `config.unknown_fields = :ignore`.
|
|
357
|
+
- Under `:ignore`, a `fields:` list with no valid path renders only identifiers and consts.
|
|
358
|
+
- `UnitResponse.selectable_paths(version: "2025-09")` lists every path.
|
|
359
|
+
- Schemas describe the full shape. With `validate_output`, a selected render is validated against a copy of its schema that requires only the selected keys.
|
|
360
|
+
|
|
361
|
+
## Schemas
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
class PriceListResponse < Versponse::Base
|
|
365
|
+
description "A product"
|
|
366
|
+
|
|
367
|
+
version "2025-09" do
|
|
368
|
+
field :id, type: :string, description: "Product ID"
|
|
369
|
+
field :price, type: :integer, nullable: true
|
|
370
|
+
field :discount, type: :integer, if: ->(product) { product[:discount] }
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
PriceListResponse.to_schema(version: "2025-09")
|
|
375
|
+
# => {
|
|
376
|
+
# "type" => "object", "description" => "A product", "required" => ["id", "price"],
|
|
377
|
+
# "properties" => {
|
|
378
|
+
# "id" => {"type" => "string", "description" => "Product ID"},
|
|
379
|
+
# "price" => {"type" => ["integer", "null"]},
|
|
380
|
+
# "discount" => {"type" => "integer"}
|
|
381
|
+
# },
|
|
382
|
+
# "additionalProperties" => false
|
|
383
|
+
# }
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
- `to_schema(version:, view:)` returns the OpenAPI schema, with `$ref`s to other components.
|
|
387
|
+
- `to_validation_schema(version:, view:, property: nil)` inlines every reference. A class that recurses into itself is named for `"#Name"` references: `$anchor` from draft 2019-09 and in OpenAPI 3.1, `$id: "#Name"` in drafts 6 and 7, `id: "#Name"` in draft 4 and OpenAPI 3.0.
|
|
388
|
+
- `component_name(view:)` drops the class name's first namespace, joins the rest, and appends the view: `PriceListResponse`, `PriceListResponseEmbedded`. `Api::V1::UserResponse` becomes `V1UserResponse`. To rename, override `self.base_component_name`, for example `def self.base_component_name = super.delete_suffix("Response")`.
|
|
389
|
+
- `to_ref(view:, property: nil)` returns `"#/components/schemas/<name>"`. An anonymous class has no component name, so it raises `Versponse::DefinitionError`.
|
|
390
|
+
- With `discriminator:`, `one_of` emits an OpenAPI `mapping` built from each variant's `const`, such as `{"order" => "#/components/schemas/OrderResponse"}`. A variant without that `const`, or two variants with the same value, raise `DefinitionError` when the schema is built.
|
|
391
|
+
- A `one_of` is a `oneOf`, in documents and validation schemas alike, except an expandable `one_of`, which is an `anyOf` of each variant's full shape and its inline collapsed shape. Without `discriminator:`, its variants need shapes a client can tell apart: if one payload fits two variants, `oneOf` rejects it, so `validate_output` raises and strict consumers such as rswag's response validation fail. For polymorphic responses, prefer `discriminator:`.
|
|
392
|
+
- `to_schema(root: "unit_types", meta: MetaResponse, collection: true)` returns a wrapper schema for the whole response body. The envelope is strict (`additionalProperties: false`) whatever `default_additional_properties` says.
|
|
393
|
+
- `description` and `schema_options` on a class reach its subclasses, so an abstract `ApplicationResponse` can carry them. Versponse looks them up when it builds schemas, so a later change to the parent still applies. A subclass's `schema_options` merge with its ancestors', and the subclass wins per key. A version's own `description` and `schema_options` win over both.
|
|
394
|
+
- Collapsed shapes are strict (`additionalProperties: false`) whatever `default_additional_properties` says, so only a real collapsed payload matches them.
|
|
395
|
+
|
|
396
|
+
`Versponse.components(version:)` returns every component of every loaded class and view. To build a whole document, with request components, wrappers, and checked `$ref`s, use an [OpenAPI document](#openapi-documents-and-rswag).
|
|
397
|
+
|
|
398
|
+
## OpenAPI documents and rswag
|
|
399
|
+
|
|
400
|
+
Declare a whole OpenAPI document in Ruby. Response components (views and collapsed shapes included), verquest request components and named wrappers are collected for each version:
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
class PublicApiDocument < Versponse::OpenAPI::Document
|
|
404
|
+
document_name "public_api"
|
|
405
|
+
versions "2025-09", "preview"
|
|
406
|
+
info "title" => "Public API", "termsOfService" => "https://example.com/terms"
|
|
407
|
+
external_docs { |version| {"url" => "https://docs.example.com/#{version}/"} }
|
|
408
|
+
server "https://api.example.com", description: "Production"
|
|
409
|
+
tag "Contacts", description: "People who rent storage"
|
|
410
|
+
security_scheme("OAuth2") { {"type" => "oauth2", "flows" => {...}} } # blocks run at build time
|
|
411
|
+
|
|
412
|
+
responses namespace: PublicApi
|
|
413
|
+
requests namespace: PublicApi # verquest body requests
|
|
414
|
+
wrapper :contact_list, PublicApi::ContactResponse, root: "contacts", collection: true, meta: PublicApi::MetaResponse # component "ContactList"
|
|
38
415
|
|
|
39
|
-
|
|
416
|
+
schema "Error", ERROR_SCHEMA # hand-written components, collision-checked
|
|
417
|
+
webhooks { |version| Webhooks.openapi(version) }
|
|
40
418
|
|
|
41
|
-
|
|
419
|
+
finalize { |document, version| document["info"]["x-build-date"] = Date.current.iso8601 }
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
PublicApiDocument.build(version: "2025-09") # => Hash, a copy you can change; schema keys are Strings
|
|
423
|
+
PublicApiDocument.validate(version: "2025-09") # => [] when valid OpenAPI and every "#/..." $ref resolves
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
- Values use OpenAPI's own key names, which are never camelized. The exception is `schema`: a hand-written component is a schema, so its keys are camelized like every other schema (`additional_properties:` becomes `additionalProperties`). Keys inside `properties` and a discriminator's `mapping`, values of `enum`, `example`, and `default`, and `x-` extensions keep their spelling.
|
|
427
|
+
- A block that takes an argument receives the version. A `finalize` block that takes one argument receives only the document.
|
|
428
|
+
- `responses` and `requests` take one or more classes, or `namespace:` with a Module or a String. At build, Versponse eager-loads the namespace through Zeitwerk, so its classes don't have to be loaded first. An undefined namespace, or one with no response classes or body requests, raises `Versponse::DefinitionError`. So does a listed class of the wrong kind, or a listed request whose `location` isn't `:body`.
|
|
429
|
+
- `wrapper` turns a Symbol name into PascalCase (`:contact_list` declares `ContactList`) and uses a String as given. Its `meta:` takes a response class, whose `$ref` describes `"meta"`, and anything else raises `Versponse::DefinitionError`. `serialize(meta:)` takes the Hash instead.
|
|
430
|
+
- `ref(name)` returns the `$ref` of a wrapper or `schema` the document declares, like a response class's `to_ref`: `PublicApiDocument.ref(:ContactList)` returns `"#/components/schemas/ContactList"`. A name the document doesn't declare, its parents included, raises `Versponse::DefinitionError`, so a typo fails when the spec loads.
|
|
431
|
+
- `ref` turns a Symbol into PascalCase as `wrapper` does, so `:ContactList` and `:contact_list` name the same component. Spell the Symbol as the component is named, so the spec matches the generated document. A String is used as given. `component_name(name)` returns the name without the `#/components/schemas/` prefix.
|
|
432
|
+
- `server` and `tag` write keywords that start with `x_` as extensions, with every underscore turned into a hyphen: `x_internal: true` becomes `"x-internal" => true`, and `x_display_name:` becomes `"x-display-name"`.
|
|
433
|
+
- `security` sets the top-level security requirement. `versions` and `document_name` called without arguments return what the class declares.
|
|
434
|
+
- `openapi` defaults to `"3.1.1"`, or `"3.0.3"` under `config.json_schema_version = :openapi30`, to match how schemas are written. Declaring `openapi "3.0.3"` while schemas are written for 3.1 (or the other way round) raises `Versponse::DefinitionError` on `build`.
|
|
435
|
+
- `build` raises `ArgumentError` for a version the document doesn't declare, and `webhooks` raise `Versponse::DefinitionError` under `:openapi30`, which has no webhooks section.
|
|
436
|
+
- `finalize` hooks run last, on a copy of the document they can change freely, components included.
|
|
437
|
+
- Two components with the same name raise `Versponse::DefinitionError`.
|
|
438
|
+
|
|
439
|
+
Subclasses inherit declarations, so several documents can share a base class:
|
|
440
|
+
|
|
441
|
+
| Behaviour | Declarations |
|
|
442
|
+
|---|---|
|
|
443
|
+
| A subclass's entry with a parent's name replaces it in place. One class declaring a name twice raises `Versponse::DefinitionError`. | `schema`, `wrapper`, `security_scheme` |
|
|
444
|
+
| Merged over the parent's, key by key | `info` |
|
|
445
|
+
| Replaces the parent's value | `external_docs`, `security`, `webhooks`, `openapi`, `document_name`, `versions` |
|
|
446
|
+
| Added to the parent's | `server`, `tag`, `responses`, `requests`, `finalize` |
|
|
447
|
+
|
|
448
|
+
In rswag's `swagger_helper.rb`:
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
config.openapi_specs = Versponse::OpenAPI.rswag_specs(PublicApiDocument)
|
|
452
|
+
# keys: "public_api/2025-09.json", "public_api/preview.json"
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`rswag_specs` needs `document_name` and `versions` on every document. It builds each document once, because rswag adds its paths to it.
|
|
456
|
+
|
|
457
|
+
In a spec, take each `$ref` from code instead of writing the string. A typo or a renamed component then fails when the spec loads, not when rswag validates the response:
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
get "Lists contacts" do
|
|
461
|
+
response "200", "contacts listed" do
|
|
462
|
+
schema "$ref" => PublicApiDocument.ref(:ContactList)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
post "Creates a contact" do
|
|
467
|
+
parameter name: :params, in: :body, schema: {"$ref" => PublicApi::ContactsCreateRequest.to_ref}
|
|
468
|
+
|
|
469
|
+
response "422", "invalid contact" do
|
|
470
|
+
schema "$ref" => PublicApiDocument.ref(:Error)
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## Output validation
|
|
476
|
+
|
|
477
|
+
Turn it on in tests and development to check every render against its schema:
|
|
478
|
+
|
|
479
|
+
```ruby
|
|
480
|
+
Versponse.configure { |config| config.validate_output = !Rails.env.production? }
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
A mismatch raises `Versponse::InvalidOutputError`:
|
|
484
|
+
|
|
485
|
+
```ruby
|
|
486
|
+
error.errors
|
|
487
|
+
# => [{pointer: "/count", type: "integer", message: "value at `/count` is not an integer", details: nil}]
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Collection items are validated one by one, so pointers look like `/1/count`.
|
|
491
|
+
|
|
492
|
+
To validate in production too, decide per request with a callable, and validate a share of renders with a sample rate:
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
Versponse.configure do |config|
|
|
496
|
+
# Called with the response class on each render; a truthy result validates it
|
|
497
|
+
config.validate_output = ->(response_class) { Current.user&.staff? || Rails.env.local? }
|
|
498
|
+
# Validates 10% of the renders validate_output allows (default 1.0)
|
|
499
|
+
config.validate_output_sample_rate = 0.1
|
|
500
|
+
end
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Versponse draws the sample once per render, so a sampled collection is validated in full. It draws the sample before it calls the callable, so the callable runs only for sampled renders. A lambda or method must accept the response class. Setting `-> { ... }` raises `ArgumentError`.
|
|
504
|
+
|
|
505
|
+
For a share that differs per request, draw it in the callable itself:
|
|
506
|
+
|
|
507
|
+
```ruby
|
|
508
|
+
config.validate_output = ->(response_class) { rand < (Current.account&.beta? ? 1.0 : 0.05) }
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Versponse compiles each validation schema once into plain Ruby checks and checks each field as it renders it. A valid render takes about 1.5 times as long as a plain one. json_schemer runs only to build the error list when a check fails, and for schemas that use keywords the compiler doesn't support.
|
|
512
|
+
|
|
513
|
+
A later block can change a value in place if the value isn't frozen all the way down. Versponse checks such a value after the render finishes, so it catches the change. A value whose schema gives only a scalar type (`type: :string`, `:number`, or `:boolean`) is checked while rendering, because a change in place can't change its type. The built-in `date` and `date-time` formatters return frozen Strings, so their values are checked while rendering too.
|
|
514
|
+
|
|
515
|
+
## Nil and errors
|
|
516
|
+
|
|
517
|
+
`on_nil` replaces `nil` for every `field`, `enum` and `array` of a class without `default:`. Declare it before the first version. Subclasses inherit it, `on_nil nil` removes an inherited one, and nested classes use their own:
|
|
518
|
+
|
|
519
|
+
```ruby
|
|
520
|
+
class ContactResponse < Versponse::Base
|
|
521
|
+
on_nil ->(contact, key, context) { "" }
|
|
522
|
+
version("2025-09") { ... }
|
|
523
|
+
end
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
`on_error` decides what happens when a property fails to render: `:raise` (the default), `:nullify`, `:omit`, or a handler `->(error, object, key, context)` whose return value is written. Set it in one of three places:
|
|
527
|
+
|
|
528
|
+
- On a property, with `on_error:` or through `with_options`.
|
|
529
|
+
- On a class, with `on_error :nullify` before the first version. Subclasses inherit it.
|
|
530
|
+
- In `config.on_error`.
|
|
531
|
+
|
|
532
|
+
The property's policy wins, then the class's, then the config's. Versponse checks `on_nil` lambdas and `on_error` handlers when you declare them. An `on_nil` lambda must take three arguments, and a handler four.
|
|
533
|
+
|
|
534
|
+
- An explicit `on_error: :nullify` needs `nullable: true`, and `:omit` needs `if:` or `unless:`, so recovered output still matches the schema. A class-level or config-level `:nullify` or `:omit` applies only to the properties it fits. The others fall through to the next level, ending at `:raise`. A handler applies to every property.
|
|
535
|
+
- A handler's return value isn't known in advance, so with `validate_output` a render that used a handler is validated in full. So is a render with a nullified value, because a declared `any_of:` may still reject `null`.
|
|
536
|
+
- `config.recoverable_errors` (default `[StandardError]`) lists what a policy recovers. Versponse's own errors aren't recovered, and a `NoMethodError` or `KeyError` no policy recovers still becomes `ExtractionError`, unless the `on_recovered_error` hook raised it.
|
|
537
|
+
- A failure inside a nested class or inline object is recovered only by an `on_error:` declared on that association (`reference :site, from: SiteResponse, nullable: true, on_error: :nullify`), and then an `ExtractionError` from it too. Class-level and config-level policies never drop a whole subtree. The child's failure is marked on the exception object, so a frozen exception can't be marked and is treated like a failure of the association itself.
|
|
538
|
+
- Versponse calls `config.on_recovered_error = ->(error, response:, key:, object:) { ... }` for every recovered error. It passes the class that recovered the error, the property's key, and the object being rendered. An error raised inside the hook propagates, and no policy recovers it, not even a parent association's.
|
|
539
|
+
|
|
540
|
+
### Errors
|
|
541
|
+
|
|
542
|
+
Every error Versponse raises inherits from `Versponse::Error`:
|
|
543
|
+
|
|
544
|
+
- `DefinitionError`: a class or document declares something invalid.
|
|
545
|
+
- `VersionNotFoundError`, `ViewNotFoundError`, `PropertyNotFoundError`: a render or schema asks for something the class doesn't have. A `nil` view, or `:collapsed` on a class without an identifier, raises `ViewNotFoundError` too.
|
|
546
|
+
- `ExtractionError`: reading a value failed. `label` names the class, version and view, `keys` the path to the property, and `detail` the cause.
|
|
547
|
+
- `InvalidOutputError`: output doesn't match its schema. The message names the first two errors. `errors` lists them all with `:pointer`, `:type`, `:message` and `:details`, and for a rendered collection they point into it (`/1/count`).
|
|
548
|
+
- `InvalidExpandError`, `InvalidFieldsError`: `expand:` or `fields:` names unknown paths.
|
|
549
|
+
|
|
550
|
+
`Versponse.reset_caches!` drops compiled plans and schemas, for example after changing configuration in tests.
|
|
551
|
+
|
|
552
|
+
## Sharing references with verquest
|
|
553
|
+
|
|
554
|
+
Versponse classes and verquest requests speak the same reference protocol (`component_name`, `to_ref`, `to_schema`, `to_validation_schema`). A response can reuse a request schema:
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
class ContactResponse < Versponse::Base
|
|
558
|
+
version "2025-09" do
|
|
559
|
+
field :id, type: :string
|
|
560
|
+
reference :address, from: PublicApi::Contacts::CreateRequest, property: :address
|
|
561
|
+
collection :tags, item: TagRequest
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
ContactResponse.to_schema(version: "2025-09")["properties"]["address"]
|
|
566
|
+
# => {"$ref" => "#/components/schemas/ContactsCreateRequest/properties/address"}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
The values of such references pass through unchanged, so they must already be JSON-ready.
|
|
570
|
+
|
|
571
|
+
A verquest request can reference a versponse class if the installed verquest supports schema-only references. verquest builds its schemas when a `version` block closes. Declare the versponse class's versions first, and don't reference a versponse class from verquest while it is still empty.
|
|
572
|
+
|
|
573
|
+
### Differences from verquest's DSL
|
|
574
|
+
|
|
575
|
+
Definitions can be copied between the gems (`one_of name:` and `inherit:` behave the same), except where responses differ from requests:
|
|
576
|
+
|
|
577
|
+
- `required:` raises. Every key is rendered, so it's required in the schema unless `if:` or `unless:` makes it conditional.
|
|
578
|
+
- `const` takes no `map:`, block or `nullable:`, because a response constant never reads the object. Use `enum` (with `nil` for nullable) for computed values.
|
|
579
|
+
- Response-only DSL: `view`, `include_view`, `view:` on `reference` and `collection`, `match:` on `one_of` variants, `identifier`, `expandable:` and `source`.
|
|
580
|
+
- Request-only DSL: `location`, `parameter_examples:`, `explode:` and conditions (`condition`, `depends_on`).
|
|
581
|
+
- Component names: versponse classes rename through `base_component_name`, which keeps the view suffix, while verquest requests override `component_name`.
|
|
582
|
+
|
|
583
|
+
verquest schemas embedded in versponse follow verquest's `config.json_schema_version`, so it must be in the same group as versponse's. The groups are:
|
|
584
|
+
|
|
585
|
+
- `:draft4`
|
|
586
|
+
- `:openapi30`
|
|
587
|
+
- `:draft6` and `:draft7`
|
|
588
|
+
- `:draft2019_09`, `:draft2020_12`, and `:openapi31`
|
|
589
|
+
|
|
590
|
+
The defaults, `:draft2020_12` and `:openapi31`, are in the same group. Otherwise, building a schema that references a verquest request raises `Versponse::DefinitionError`.
|
|
591
|
+
|
|
592
|
+
## Formats and validation notes
|
|
593
|
+
|
|
594
|
+
- Only properties with a `format:` are formatted. Blueprinter formats every Time, but here a Time field without `format: "date-time"` is emitted as is, and output validation fails it.
|
|
595
|
+
- `date-time` formats Time, DateTime, and Date (as midnight) as ISO 8601 in UTC with milliseconds, like Rails' JSON (`2025-09-01T10:00:00.000Z`). `date` formats Date and Time as an ISO 8601 date. To set the fraction digits per class and version, use [`time_precision`](#time-precision). `date` keeps the value's own calendar day, while `date-time` converts to UTC. For 23:30 at -05:00 on 1 January 2025, `date` gives `2025-01-01` and `date-time` gives `2025-01-02T04:30:00.000Z`.
|
|
596
|
+
- Validation checks only the rendered data, not the `root:` and `meta:` envelope.
|
|
597
|
+
- `field :metadata, type: :object, additional_properties: {type: "string"}` describes a free-form map. Its value passes through unchanged.
|
|
598
|
+
- In apps and test suites with many response classes, pass `classes:` to `Versponse.components`, or keep component names unique.
|
|
599
|
+
|
|
600
|
+
## Configuration
|
|
601
|
+
|
|
602
|
+
```ruby
|
|
603
|
+
Versponse.configure do |config|
|
|
604
|
+
config.current_version = -> { Current.api_version }
|
|
605
|
+
config.validate_output = Rails.env.local?
|
|
606
|
+
end
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
| Setting | Default |
|
|
610
|
+
|---|---|
|
|
611
|
+
| `current_version` | none |
|
|
612
|
+
| `version_resolver` | closest earlier version. A custom resolver is called with `(requested, names)`, where `names` is sorted as Strings, and returns the closest name at or before `requested`, or `nil`. It also orders `supported_versions` bounds. |
|
|
613
|
+
| `json_generator` | `JSON.generate` (or a callable such as `->(hash) { Oj.dump(hash, mode: :compat) }`) |
|
|
614
|
+
| `json_schema_version` | `:openapi31` (also `:openapi30`, `:draft2020_12`, `:draft2019_09`, `:draft7`, `:draft6`, `:draft4`, as in verquest) |
|
|
615
|
+
| `validate_output` | `false` (or `true`, or a callable called with the response class per render) |
|
|
616
|
+
| `validate_output_sample_rate` | `1.0`. The share of renders that `validate_output` allows and that are validated. |
|
|
617
|
+
| `formatters` | `date-time` and `date`, keyed by JSON Schema format |
|
|
618
|
+
| `custom_field_types` | `{}`. Custom types in verquest's shape, described in [Custom field types](#custom-field-types). |
|
|
619
|
+
| `default_additional_properties` | `false` |
|
|
620
|
+
| `unknown_expand` | `:raise` (or `:ignore`) |
|
|
621
|
+
| `unknown_fields` | `:raise` (or `:ignore`) |
|
|
622
|
+
| `field_order` | `:declaration` (or `:alphabetical`: identifier fields first, then by key, like blueprinter's `sort_fields_by :name_asc`) |
|
|
623
|
+
| `on_error` | `:raise` (or `:nullify`, `:omit`, a handler `->(error, object, key, context)`) |
|
|
624
|
+
| `recoverable_errors` | `[StandardError]` |
|
|
625
|
+
| `on_recovered_error` | none. Called with `(error, response:, key:, object:)`. |
|
|
626
|
+
|
|
627
|
+
`json_schema_version` works as in verquest:
|
|
628
|
+
|
|
629
|
+
- Output validation uses that version's meta-schema.
|
|
630
|
+
- `const` is written as a one-value `enum` under `:draft4` and `:openapi30`.
|
|
631
|
+
- Recursive classes are named with `$anchor` from 2019-09, a plain-name `$id` in drafts 6 and 7, and `id` in draft 4 and OpenAPI 3.0.
|
|
632
|
+
|
|
633
|
+
`:openapi30` writes `nullable: true` instead of a `"null"` type, and documents declare `openapi: "3.0.3"`. A nullable enum also lists `null` among its values. A nullable `reference`, `one_of`, or expandable property gets a null branch, `{"nullable": true, "enum": [null]}`: a reference is written as `{"anyOf": [{"$ref": ...}, {"nullable": true, "enum": [null]}]}`, and a `one_of` or expandable `anyOf` lists the branch among its own. Strict 3.0 validators read `nullable` only beside a `type` or `enum`, so this is the form in which they accept `null`. Output validation uses the 3.1 forms internally.
|
|
634
|
+
|
|
635
|
+
Versponse caches plans and schemas. Setting any of these clears the caches, because they change output: `json_schema_version`, `formatters`, `version_resolver`, `field_order`, `default_additional_properties`, `on_error`, and `recoverable_errors`. Set `custom_field_types` before response classes load, because properties resolve their types when they're defined.
|
|
636
|
+
|
|
637
|
+
### Custom field types
|
|
638
|
+
|
|
639
|
+
Custom types use verquest's shape, so one Hash can configure both gems:
|
|
640
|
+
|
|
641
|
+
```ruby
|
|
642
|
+
FIELD_TYPES = {
|
|
643
|
+
email: {type: "string", schema_options: {format: "email"}},
|
|
644
|
+
site_id: {type: "string", schema_options: {pattern: "^site_"}},
|
|
645
|
+
metadata: {type: "object", schema_options: {additionalProperties: {"type" => "string"}, maxProperties: 20}}
|
|
646
|
+
}.freeze
|
|
647
|
+
|
|
648
|
+
Verquest.configure { |config| config.custom_field_types = FIELD_TYPES }
|
|
649
|
+
Versponse.configure { |config| config.custom_field_types = FIELD_TYPES }
|
|
650
|
+
|
|
651
|
+
field :id, type: :site_id # {"type" => "string", "pattern" => "^site_"}
|
|
652
|
+
array :site_ids, type: :site_id # items use the custom schema
|
|
653
|
+
field :email, type: :email, description: "Contact email" # field options win on conflict
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Patterns are ECMA-262, in documents and output validation alike, so `^` and `$` anchor the whole string. A Regexp `pattern:`, in `schema_options` or a property's options, is written as one: `\A` and `\z` become `^` and `$`. A Regexp with options (`/i`, `/m`, `/x`, or an inline group such as `(?i)`) or `\Z` raises `Versponse::DefinitionError`.
|
|
657
|
+
|
|
658
|
+
- `type` must be a built-in type, and a custom name can't replace a built-in one.
|
|
659
|
+
- `required:` is ignored, because it only means something for requests.
|
|
660
|
+
- A custom type with a `format` gets that format's formatter, so `type: :datetime` with `format: "date-time"` renders Times as ISO 8601.
|
|
661
|
+
- Types resolve when a field is defined, so set them in an initializer before response classes load.
|
|
662
|
+
- Custom types can't be part of a type union: `type: [:site_id, :string]` raises `Versponse::DefinitionError`.
|
|
663
|
+
|
|
664
|
+
## Rails
|
|
665
|
+
|
|
666
|
+
```ruby
|
|
667
|
+
render json: UnitTypeResponse.serialize_json(record, view: :embedded)
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Compile everything at boot to catch definition errors early:
|
|
671
|
+
|
|
672
|
+
```ruby
|
|
673
|
+
# config/initializers/versponse.rb
|
|
674
|
+
Rails.application.config.after_initialize do
|
|
675
|
+
Rails.application.eager_load!
|
|
676
|
+
Versponse.eager_load!
|
|
677
|
+
end
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
## Performance
|
|
681
|
+
|
|
682
|
+
`bundle exec rake bench` compares versponse with alba and blueprinter on flat, nested, and collection payloads, and prints allocations per serialized object.
|
|
683
|
+
|
|
684
|
+
Iterations per second on Ruby 4.0.6 (arm64-darwin), higher is better:
|
|
685
|
+
|
|
686
|
+
| Payload | versponse | versponse + validation | alba | blueprinter |
|
|
687
|
+
|---|---|---|---|---|
|
|
688
|
+
| flat (15 fields) | 459.1k | 273.2k | 204.1k | 105.7k |
|
|
689
|
+
| nested (2 levels) | 183.0k | 127.4k | 63.2k | 37.3k |
|
|
690
|
+
| nested with `expand: ["site"]` | 171.1k | 120.2k | | |
|
|
691
|
+
| collection (1000 nested) | 201.5 | 148.0 | 63.4 | 37.9 |
|
|
692
|
+
|
|
693
|
+
These numbers are without YJIT. With `ruby --yjit`, versponse renders the same payloads 1.7 to 2 times faster.
|
|
694
|
+
|
|
695
|
+
## License
|
|
42
696
|
|
|
43
|
-
|
|
697
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|