Skip to content

Commit c6079c0

Browse files
ahxclaude
andcommitted
Add opt-in jsonschema_rs schema backend
Add Schema::JsonschemaRsBackend, a faster, opt-in backend powered by the jsonschema_rs gem (Ruby binding to the Rust `jsonschema` crate). Enable it globally with `OpenapiFirst.plugin :jsonschema_rs`. The backend supports OpenAPI 3.1 only (3.1 is JSON-Schema-2020-12 compatible); loading a 3.0 document while it is enabled raises, because 3.0 is not standard JSON Schema. It reproduces the features openapi_first relies on: - internal, nested, recursive and external/relative $ref resolution, via a wrapper $ref into a copy of the node's document plus a file-loading retriever - readOnly/writeOnly enforcement per access mode: forbidden-if-present detected at all depths through jsonschema_rs evaluate() annotations, plus top-level required-relaxation, matching json_schemer's messages - top-level default insertion and after_property_validation hooks - mapping jsonschema_rs's generic error kinds to json_schemer's keyword vocabulary so ValidationError#message stays identical The backend is global. Setting it per definition is rejected by ChildConfiguration#schema_backend=, so the plugin itself stays unaware of ChildConfiguration. jsonschema_rs is an optional dev/test dependency (require: false) in all Gemfiles, never a runtime dependency in the gemspec. Documented in the README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9e9c721 commit c6079c0

12 files changed

Lines changed: 638 additions & 1 deletion

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ group :test, :development do
1111
gem 'actionpack'
1212
gem 'benchmark'
1313
gem 'bundler'
14+
# Optional: powers the opt-in `OpenapiFirst.plugin :jsonschema_rs` backend. Not a runtime dependency.
15+
gem 'jsonschema_rs', require: false
1416
gem 'minitest'
1517
gem 'rack-test'
1618
gem 'rails'

Gemfile.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ GEM
116116
hana (~> 1.3)
117117
regexp_parser (~> 2.0)
118118
simpleidn (~> 0.2)
119+
jsonschema_rs (0.46.6-arm64-darwin)
120+
bigdecimal (>= 3.1, < 5)
121+
jsonschema_rs (0.46.6-x86_64-linux)
122+
bigdecimal (>= 3.1, < 5)
119123
language_server-protocol (3.17.0.5)
120124
lint_roller (1.1.0)
121125
logger (1.7.0)
@@ -286,6 +290,7 @@ DEPENDENCIES
286290
actionpack
287291
benchmark
288292
bundler
293+
jsonschema_rs
289294
minitest
290295
openapi_first!
291296
rack (>= 3.0.0)

Gemfile.multi_json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ gem 'multi_json'
99
group :test, :development do
1010
gem 'actionpack'
1111
gem 'bundler'
12+
# Optional: powers the opt-in `OpenapiFirst.plugin :jsonschema_rs` backend. Not a runtime dependency.
13+
gem 'jsonschema_rs', require: false
1214
gem 'minitest'
1315
gem 'rack-test'
1416
gem 'rails'

Gemfile.rack2

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ gem 'rack', '< 3.0.0'
99
group :test, :development do
1010
gem 'actionpack'
1111
gem 'bundler'
12+
# Optional: powers the opt-in `OpenapiFirst.plugin :jsonschema_rs` backend. Not a runtime dependency.
13+
gem 'jsonschema_rs', require: false
1214
gem 'minitest'
1315
gem 'rack-test'
1416
gem 'rails'

Gemfile.rack30

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ gem 'rack', '>= 3.0.0', '< 3.1.0'
99
group :test, :development do
1010
gem 'actionpack'
1111
gem 'bundler'
12+
# Optional: powers the opt-in `OpenapiFirst.plugin :jsonschema_rs` backend. Not a runtime dependency.
13+
gem 'jsonschema_rs', require: false
1214
gem 'minitest'
1315
gem 'rack-test'
1416
gem 'rails'

Gemfile.rails6

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ gem 'rackup'
88

99
group :test, :development do
1010
gem 'bundler'
11+
# Optional: powers the opt-in `OpenapiFirst.plugin :jsonschema_rs` backend. Not a runtime dependency.
12+
gem 'jsonschema_rs', require: false
1113
gem 'minitest'
1214
gem 'rack-test'
1315
gem 'rails', '~> 6.1'

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ end
3737
- [Contract testing](#contract-testing)
3838
- [Test assertions](#test-assertions)
3939
- [Manual use](#manual-use)
40-
- [Framework integration](#framework-integration)
4140
- [Hooks](#hooks)
41+
- [Schema validation backends](#schema-validation-backends)
42+
- [Framework integration](#framework-integration)
4243
- [Alternatives](#alternatives)
4344
- [Frequently Asked Questions](#frequently-asked-questions)
4445
- [Development](#development)
@@ -410,6 +411,32 @@ OpenapiFirst.configure do |config|
410411
end
411412
```
412413

414+
## Schema validation backends
415+
416+
By default openapi_first validates schemas using [json_schemer](https://github.com/davishmcclurg/json_schemer), which supports both OpenAPI 3.0 and 3.1.
417+
418+
If your API descriptions are OpenAPI **3.1** you can opt into a significantly faster backend powered by [jsonschema_rs](https://rubygems.org/gems/jsonschema_rs) (a Ruby binding to the Rust `jsonschema` crate). The speedup scales with how much validation a request involves – it is dramatic for large request/response bodies.
419+
420+
Add the gem to your `Gemfile`:
421+
422+
```ruby
423+
gem 'jsonschema_rs'
424+
```
425+
426+
`jsonschema_rs` ships precompiled native gems for the common platforms (e.g. `arm64-darwin`, `x86_64-linux`), so no Rust toolchain is needed there. On a platform without a precompiled gem, installation falls back to compiling the Rust extension, which requires a Rust toolchain.
427+
428+
Then enable the backend globally:
429+
430+
```ruby
431+
OpenapiFirst.plugin :jsonschema_rs
432+
# or
433+
OpenapiFirst.configure do |config|
434+
config.plugin :jsonschema_rs
435+
end
436+
```
437+
438+
The backend is **global**: it applies to all loaded API descriptions. Because of that it cannot be enabled per definition (`OpenapiFirst.load(…) { |c| c.plugin :jsonschema_rs }` raises). It also only supports OpenAPI 3.1 – loading a 3.0 document while it is enabled raises an error, because OpenAPI 3.0 is not standard JSON Schema (e.g. `nullable`).
439+
413440
## Framework integration
414441

415442
Using rack middlewares is supported in probably all Ruby web frameworks.

lib/openapi_first/child_configuration.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ def initialize(parent:)
1414

1515
private attr_reader :parent
1616

17+
# The schema backend is global, so it cannot be set per definition (it would silently affect everything).
18+
def schema_backend=(_value)
19+
raise ArgumentError,
20+
'The schema backend is global and cannot be set per definition. ' \
21+
'Configure it via `OpenapiFirst.plugin :jsonschema_rs` or ' \
22+
'`OpenapiFirst.configure { |config| config.schema_backend = ... }`.'
23+
end
24+
1725
HOOKS.each do |hook|
1826
define_method(hook) do |&block|
1927
return hooks[hook].chain(parent.hooks[hook]) if block.nil?
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../schema/jsonschema_rs_backend'
4+
5+
module OpenapiFirst
6+
module Plugins
7+
# Enables the faster, opt-in {OpenapiFirst::Schema::JsonschemaRsBackend} schema validation backend.
8+
#
9+
# The backend is global: it applies to all Definitions regardless of where it is configured. Because of
10+
# that, enabling it per definition (`OpenapiFirst.load(...) { |c| c.plugin :jsonschema_rs }`) is rejected
11+
# by ChildConfiguration#schema_backend= – it would silently affect everything. Enable it globally instead:
12+
#
13+
# OpenapiFirst.plugin :jsonschema_rs
14+
# # or
15+
# OpenapiFirst.configure { |config| config.plugin :jsonschema_rs }
16+
module JsonschemaRs
17+
def self.configure(config, **)
18+
config.schema_backend = OpenapiFirst::Schema::JsonschemaRsBackend
19+
end
20+
end
21+
end
22+
end
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# frozen_string_literal: true
2+
3+
require 'jsonschema_rs'
4+
5+
module OpenapiFirst
6+
module Schema
7+
# An opt-in schema validation backend powered by the {https://rubygems.org/gems/jsonschema_rs jsonschema_rs}
8+
# gem (a Ruby binding to the Rust `jsonschema` crate). It is significantly faster than the default
9+
# {OpenapiFirst::Schema::JsonSchemerBackend}, but only supports OpenAPI 3.1 (which is JSON-Schema-2020-12
10+
# compatible). OpenAPI 3.0 is not standard JSON Schema and is rejected at load time.
11+
#
12+
# Enable it globally:
13+
# OpenapiFirst.plugin :jsonschema_rs
14+
class JsonschemaRsBackend
15+
# The key under which a node's subtree is injected into a copy of its document so that internal $refs
16+
# resolve against the whole document while validation runs against just the subtree.
17+
SUBJECT_KEY = 'x-openapi-first-subject'
18+
19+
# OpenAPI versions this backend can validate. 3.0 is unsupported (see class docs).
20+
# @return [Array<String>]
21+
def self.supported_openapi_versions = %w[3.1]
22+
23+
# @param document [Hash] The resolved OpenAPI document (unused; accepted for backend interface parity).
24+
# @param filepath [String, nil] Unused; accepted for backend interface parity (per-node paths come from the node).
25+
# @param file_loader [OpenapiFirst::FileLoader] Loads referenced files from disk.
26+
def initialize(document:, filepath:, file_loader:) # rubocop:disable Lint/UnusedMethodArgument
27+
@file_loader = file_loader
28+
end
29+
30+
# Build a schema validator for a node inside the document, keeping its $refs intact.
31+
# @param node [OpenapiFirst::RefResolver::Hash]
32+
# @param after_property_validation [Enumerable<#call>, nil]
33+
# @return [Validator]
34+
def build_node(node, after_property_validation: nil)
35+
Validator.new(
36+
raw: node.value,
37+
file_loader: @file_loader,
38+
context: node.context,
39+
filepath: node.filepath,
40+
dir: node.dir,
41+
properties: top_level_properties(node),
42+
after_property_validation:
43+
)
44+
end
45+
46+
# Build a schema validator for a self-contained schema Hash (no external context).
47+
# @param schema [Hash]
48+
# @return [Validator]
49+
def build_inline(schema)
50+
Validator.new(raw: schema, file_loader: @file_loader)
51+
end
52+
53+
private
54+
55+
def top_level_properties(node)
56+
node.resolved['properties'] || {}
57+
end
58+
59+
# Wraps a compiled jsonschema_rs validator and adapts it to the openapi_first backend interface.
60+
# @visibility private
61+
class Validator
62+
def initialize(raw:, file_loader:, context: nil, filepath: nil, dir: nil, properties: nil,
63+
after_property_validation: nil)
64+
@raw = raw
65+
@file_loader = file_loader
66+
@context = context
67+
@filepath = filepath
68+
@dir = dir
69+
@properties = properties
70+
@after_property_validation = after_property_validation
71+
end
72+
73+
# The raw schema Hash this validator was built from (used for default insertion).
74+
# @return [Hash]
75+
def value = @raw
76+
77+
def valid?(data)
78+
validator.valid?(prepare(data))
79+
end
80+
81+
# @return [Array<Hash>] json_schemer-shaped error Hashes.
82+
def validate(data, access_mode: nil)
83+
prepared = prepare(data)
84+
errors = []
85+
validator.each_error(prepared) do |error|
86+
next if access_mode && suppress_required?(error, access_mode)
87+
88+
errors << map_error(error)
89+
end
90+
errors.concat(access_mode_errors(prepared, access_mode)) if access_mode
91+
errors
92+
end
93+
94+
private
95+
96+
def validator
97+
@validator ||= JSONSchema.validator_for(compiled_schema, **compile_options)
98+
end
99+
100+
def compile_options
101+
options = { validate_formats: true }
102+
options[:retriever] = retriever if @context
103+
options
104+
end
105+
106+
# When the schema lives inside a document, validate against a $ref into a copy of that document so
107+
# internal, nested and recursive $refs resolve. Self-contained schemas compile directly.
108+
def compiled_schema
109+
return @raw unless @context
110+
111+
{ '$ref' => "#{document_uri}#/#{SUBJECT_KEY}" }
112+
end
113+
114+
def document_uri
115+
@document_uri ||=
116+
if @filepath
117+
"file://#{File.absolute_path(@filepath)}"
118+
else
119+
"file://#{@dir}/openapi-first-document"
120+
end
121+
end
122+
123+
def served_document
124+
@served_document ||= @context.dup.tap { |doc| doc[SUBJECT_KEY] = @raw }
125+
end
126+
127+
def retriever
128+
documents = { document_uri => served_document }
129+
lambda do |uri|
130+
uri = uri.to_s
131+
documents[uri] || load_external(uri)
132+
end
133+
end
134+
135+
def load_external(uri)
136+
raise ArgumentError, "Unsupported reference URI: #{uri}" unless uri.start_with?('file://')
137+
138+
@file_loader.load(uri.delete_prefix('file://'))
139+
end
140+
141+
# Insert top-level property defaults and fire after_property_validation hooks, mirroring (at the top
142+
# level) what json_schemer does during traversal. Mutates and returns +data+.
143+
def prepare(data)
144+
return data unless @properties && data.is_a?(::Hash)
145+
146+
insert_defaults(data)
147+
fire_after_property_validation(data)
148+
data
149+
end
150+
151+
def insert_defaults(data)
152+
@properties.each do |name, subschema|
153+
next unless subschema.is_a?(::Hash) && subschema.key?('default')
154+
155+
data[name] = subschema['default'] unless data.key?(name)
156+
end
157+
end
158+
159+
def fire_after_property_validation(data)
160+
return unless @after_property_validation&.any?
161+
162+
@properties.each do |name, subschema|
163+
next unless data.key?(name)
164+
165+
@after_property_validation.each { |hook| hook.call(data, name, subschema, nil) }
166+
end
167+
end
168+
169+
# In write access mode a readOnly property and in read access mode a writeOnly property is not required,
170+
# so suppress the corresponding top-level `required` error.
171+
def suppress_required?(error, access_mode)
172+
return false unless error.kind.name == 'required'
173+
174+
subschema = @properties[error.kind.value[:property]]
175+
subschema.is_a?(::Hash) && subschema[access_mode == 'write' ? 'readOnly' : 'writeOnly'] == true
176+
end
177+
178+
# A readOnly property must not appear in a request body (write) and a writeOnly property must not appear
179+
# in a response body (read). jsonschema_rs surfaces readOnly/writeOnly as annotations on present
180+
# properties, which we turn into errors at every depth.
181+
def access_mode_errors(data, access_mode)
182+
flag = access_mode == 'write' ? 'readOnly' : 'writeOnly'
183+
validator.evaluate(data).annotations.filter_map do |annotation|
184+
values = annotation[:annotations]
185+
next unless values.is_a?(::Hash) && values[flag] == true
186+
187+
{
188+
'data' => nil,
189+
'data_pointer' => annotation[:instanceLocation],
190+
'schema_pointer' => annotation[:schemaLocation],
191+
'type' => flag,
192+
'details' => nil,
193+
'schema' => { flag => true }
194+
}
195+
end
196+
end
197+
198+
def map_error(error)
199+
type, details, schema = translate(error.kind)
200+
{
201+
'data' => error.instance,
202+
'data_pointer' => error.instance_path_pointer,
203+
'schema_pointer' => error.schema_path_pointer,
204+
'type' => type,
205+
'details' => details,
206+
'schema' => schema
207+
}
208+
end
209+
210+
# Map jsonschema_rs's generic error kinds to json_schemer's keyword vocabulary, reconstructing the
211+
# minimal schema/details that {OpenapiFirst::Schema::ValidationError#message} needs.
212+
def translate(kind)
213+
value = kind.value
214+
case kind.name
215+
when 'type'
216+
types = value[:types]
217+
single = types.length == 1
218+
[single ? types.first : 'type', nil, { 'type' => single ? types.first : types }]
219+
when 'required'
220+
['required', { 'missing_keys' => [value[:property]] }, nil]
221+
when 'pattern' then ['pattern', nil, { 'pattern' => value[:pattern] }]
222+
when 'format' then ['format', nil, { 'format' => value[:format] }]
223+
when 'const' then ['const', nil, { 'const' => value[:expected_value] }]
224+
when 'enum' then ['enum', nil, { 'enum' => value[:options] }]
225+
when 'minimum' then ['minimum', nil, { 'minimum' => value[:limit] }]
226+
when 'maximum' then ['maximum', nil, { 'maximum' => value[:limit] }]
227+
else [kind.name, nil, nil]
228+
end
229+
end
230+
end
231+
end
232+
end
233+
end

0 commit comments

Comments
 (0)