Project

schemurai

0.0
The project is in a healthy, maintained state
Validates Ruby values against JSON Schema Draft 7, 2019-09, and 2020-12 schemas.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 0
 Project Readme

Schemurai

Gem Version test / rspec lint / rubocop lint / rbs

A JSON Schema validator for Ruby with reusable compiled schemas and detailed validation errors. It supports Draft 7, Draft 2019-09, and Draft 2020-12 and covers the required cases in the official JSON-Schema-Test-Suite, as well as the applicable optional tests for numeric precision, ECMA-262 regular expressions, content validation, anchors, and dynamic references. The dialect is selected from the schema's $schema URI; schemas that omit $schema use Draft 7 for compatibility.

Choose the default Ruby evaluator, the Ruby VM backend, or the optional experimental Rust native backend. See backend selection for setup and requirements.

Schemurai ships RBS declarations for its public API in sig/schemurai.rbs.

require "schemurai"

schema = {
  "$schema" => "https://json-schema.org/draft/2020-12/schema",
  "type" => "object",
  "properties" => {
    "order_id" => { "type" => "string", "pattern" => "^ord_[0-9]{6}$" },
    "customer" => { "$ref" => "#/$defs/customer" },
    "items" => {
      "type" => "array",
      "minItems" => 1,
      "items" => { "$ref" => "#/$defs/line_item" }
    },
    "created_at" => { "type" => "string", "format" => "date-time" }
  },
  "required" => %w[order_id customer items created_at],
  "additionalProperties" => false,
  "$defs" => {
    "customer" => {
      "type" => "object",
      "properties" => {
        "id" => { "type" => "integer", "minimum" => 1 },
        "name" => { "type" => "string", "minLength" => 1 },
        "tier" => { "enum" => %w[standard premium] }
      },
      "required" => %w[id name],
      "additionalProperties" => false
    },
    "line_item" => {
      "type" => "object",
      "properties" => {
        "sku" => { "type" => "string", "pattern" => "^SKU-[A-Z0-9]{8}$" },
        "quantity" => { "type" => "integer", "minimum" => 1 },
        "unit_price" => { "type" => "number", "minimum" => 0 }
      },
      "required" => %w[sku quantity unit_price],
      "additionalProperties" => false
    }
  }
}

order = {
  "order_id" => "ord_123456",
  "customer" => { "id" => 42, "name" => "Ada Lovelace", "tier" => "premium" },
  "items" => [
    { "sku" => "SKU-ABC12345", "quantity" => 2, "unit_price" => 19.95 }
  ],
  "created_at" => "2026-08-31T10:15:00+09:00"
}

validator = Schemurai.compile(schema, format: true)
validator.valid?(order) # => true

invalid_order = order.merge(
  "items" => [order.fetch("items").first.merge("quantity" => 0)]
)
result = validator.validate(invalid_order)
result.valid? # => false
result.errors.each do |error|
  puts "#{error.instance_path}: #{error.message}"
end

For repeated validation, compile the schema once and reuse the validator. A schema registry owns the compiled resource graph, so schemas compiled by the same registry also share their compiled external references.

registry = Schemurai::SchemaRegistry.new(
  schemas: {
    "https://example.test/positive" => { "type" => "integer", "minimum" => 1 }
  }
)
validator = registry.compile({"$ref" => "https://example.test/positive"})

validator.valid?(1) # => true
validator.valid?(0) # => false
validator.validate(0).errors # detailed errors, without recompiling the schema

Schemurai.compile is a convenience for compiling a standalone validator. Repeatedly compiling the same schema object with one registry reuses its compiled schema graph. Schemurai.validate and .valid? continue to accept raw JSON-like schemas and perform compilation internally.

Schema inputs are checked recursively before compilation. They must use the built-in JSON-shaped Ruby classes described in docs/compatibility-domain.md; rejected values are never retained by a registry.

To resolve external references, pass a mapping of URIs to schemas using schemas:.

Schemurai.valid?(
  { "$ref" => "https://example.test/positive" },
  3,
  schemas: { "https://example.test/positive" => { "type" => "integer", "minimum" => 1 } }
)

Draft 2019-09 and Draft 2020-12 support their dialect-specific keywords, including $recursiveRef / $dynamicRef, $defs, dependentSchemas, dependentRequired, minContains, maxContains, and the unevaluated* applicators. Enable optional validation for contentEncoding and contentMediaType with content: true. Enable optional format assertions with format: true; support for each format is listed separately below.

Validation errors

result = Schemurai.validate(
  { "$ref" => "https://example.test/positive" },
  -1,
  schemas: { "https://example.test/positive" => { "type" => "integer", "minimum" => 1 } }
)

validation_error = result.errors[0]
p validation_error.keyword # => "minimum"
p validation_error.instance_path # => ""
p validation_error.schema_path # => "/$ref/minimum"
p validation_error.message # => "number must be greater than or equal to 1"

Result#errors contains Schemurai::ValidationError objects. Check the following attributes to investigate schema errors:

  • keyword identifies the failed JSON Schema keyword. Schemurai uses falseSchema for a boolean false schema, which has no keyword of its own.
  • instance_path is a JSON Pointer to the value that failed validation.
  • schema_path is a JSON Pointer to the schema location that produced the error.
  • message is a human-readable String describing the failure.

An empty path points to the instance or schema root. Error order is stable. The presence and type of message are part of the public API, but its exact wording is not. Use keyword, instance_path, and schema_path, rather than matching message, when handling errors programmatically. ValidationError#to_h returns these four attributes as a Hash.

Thread / Ractor support

To share a registry between threads or Ractors, finish registering schemas and make the registry shareable first. This eagerly compiles every registered schema, resolves all references, and makes the registry deeply immutable.

registry = Schemurai::SchemaRegistry.new(
  schemas: {
    "https://example.test/positive" => { "type" => "integer", "minimum" => 1 },
    "https://example.test/value" => { "$ref" => "https://example.test/positive" }
  },
  # enable virtual machine backend that is faster for iterative validation.
  backend: :vm
)
registry.make_shareable

# Each thread or Ractor creates and owns its validator.
validator = registry.validator_for("https://example.test/value")

make_shareable calls Ractor.make_shareable internally. It raises a ResolutionError if a reference cannot be resolved. After it returns, validator_for is read-only and may be called concurrently, while compile is no longer available. A Validator contains per-validation mutable state and must not be shared between threads or Ractors. With the VM and native backends, the registry compiles and shares its bytecode before this transition. The native backend also prepares shared native instruction tables for all four combinations of content and format; validator_for does not recompile them in each Ractor. Native regex search caches and Ruby runtime helper state belong to each validator. Preparing these tables adds time and memory to make_shareable.

Meta-schema validation

Meta-schema validation is opt-in so ordinary compilation does not pay its additional time and memory cost. Set validate_schema: true when creating a SchemaRegistry to validate every schema registered through schemas: and every schema passed to compile. The convenience compile, validate, and valid? methods accept the same option and apply it to their internal registry. Schemas without $schema use the Draft 7 meta-schema. An invalid schema raises Schemurai::InvalidSchemaError, whose result contains the detailed validation errors.

Schemurai.compile(schema, validate_schema: true)

registry = Schemurai::SchemaRegistry.new(
  schemas: external_schemas,
  validate_schema: true
)
validator = registry.compile(schema)

result = Schemurai.validate_schema(schema)
result.valid? # whether the schema itself is valid

SchemaRegistry#validate_schema and #valid_schema? perform explicit checks regardless of the registry option and reuse meta-schema graph data within that registry. The stateful validator used for a check is temporary; no global validator is retained.

Opt-in validation limits (experimental)

max_errors: and timeout: are optional kwargs. Both default to nil (unlimited) and apply independently to each validation call. max_errors must be a nonnegative Integer; timeout must be finite nonnegative Integer or Float seconds. No settings object is needed.

validator = Schemurai.compile(schema, max_errors: 100, timeout: 0.1)
begin
  result = validator.validate(order)
rescue Schemurai::LimitExceeded => error
  # Evaluation is incomplete; no partial result is returned.
  warn "Validation stopped at #{error.limit}"
end

validator.validate(order, max_errors: 10) # retains the compiled timeout
validator.validate(order, timeout: nil)  # retains the compiled error cap
validator.validate(order, max_errors: nil, timeout: nil) # disables both
validator.valid?(order) # applies the timeout, ignores max_errors

SchemaRegistry#compile, #validator_for, the convenience validate / valid? methods and explicit meta-schema checks also accept these kwargs. Overrides replace only the specified option. Settings belong to each compiled validator; a shareable registry retains no mutable invocation counters.

The error cap checks before constructing the next emitted diagnostic. Exactly max_errors errors may be returned; attempting another raises LimitExceeded with limit == :max_errors, maximum and attempted counts. Suppressed applicator trials do not consume the cap. Boolean checks ignore the error cap and use the ordinary evaluator when no timeout is set.

The timeout uses a monotonic elapsed-time deadline, including diagnostic rendering and native runtime helper calls and retries. It checks at entry, before returning and every 256 checkpoints in evaluation, container scans and structural traversal. Ruby string/content operations also check before and after expensive calls. Zero seconds stops even a trivial call. Expiry raises LimitExceeded with limit == :timeout; maximum and attempted are configured and elapsed seconds. Native returns an exhaustion status and releases Rust resources before Ruby raises. A limit hit is terminal. Native validation does not fall back to VM execution, with or without invocation limits.

This implementation never uses Ruby Timeout, timer threads, signals or asynchronous exception injection. It is cooperative: one regexp search, scalar operation, JSON decode, allocation or custom format callback may overrun until the next checkpoint. GC and descheduling count toward elapsed time; the polling interval bounds skipped checkpoints, not elapsed overrun. Compilation, registration and automatic compile-time meta-schema validation are outside the timeout. Explicit meta-schema checks can set their own timeout.

Use the error cap to bound diagnostic count and the timeout to bound prolonged traversal. They protect different resources: a deadline does not bound the number of diagnostics, and an error cap cannot stop a long successful check. Input byte size and nesting depth belong at the parsing boundary. An error cap does not bound the size of an individual message. Preempting individual scalar operations requires operation-specific limits or a supervised worker process. The backends retain their respective Ruby, VM and native execution paths and native memoization. Timed native bulk pattern/format/content and Fixnum contains scans use per-item evaluation so successful scans can poll.

Backend selection

Schemurai.backend, SchemaRegistry#backend, and Validator#backend expose the selected backend. Pass backend: :ruby to force the Ruby oracle or backend: :vm to ahead-of-time compile schemas for the validator virtual machine. Pass backend: :native to use the experimental Rust VM extension. Releases include precompiled gems on RubyGems.org for CRuby 4.0 on these platforms:

OS CPU libc Gem platform
Linux x64 glibc 2.36+ x86_64-linux-gnu
Linux arm64 glibc 2.36+ aarch64-linux-gnu
Linux x64 musl (Alpine 3.23+) x86_64-linux-musl
Linux arm64 musl (Alpine 3.23+) aarch64-linux-musl
macOS 14+ x64 — x86_64-darwin
macOS 14+ arm64 — arm64-darwin

RubyGems/Bundler selects the matching platform gem during normal installation; these gems need no C compiler, Rust, or Ruby development headers. Each platform gem bundles its supported Ruby minor ABIs and restricts required_ruby_version to that range. Ruby patch releases share the same binary. To build from source, use gem install schemurai --platform ruby or Bundler's force_ruby_platform. See native gem releases for the build matrix.

Building the native extension from source requires a C toolchain, Ruby development headers, and Rust 1.93 or newer. If cargo is absent, installation of the source gem skips the optional native extension and Ruby/VM remain available; explicitly selecting :native then raises LoadError. Native execution borrows Ruby values directly; its current coverage and remaining VM delegation are documented below. Schema graph construction remains shared Ruby infrastructure. Environment-based selection is documented in docs/backend-selection.md.

JSON Schema conformance

Capability Draft 7 Draft 2019-09 Draft 2020-12
Required JSON-Schema-Test-Suite cases Supported Supported Supported
Dialect-specific references $ref $ref, $recursiveRef $ref, $dynamicRef
unevaluatedItems / unevaluatedProperties Not applicable Supported Supported
contentEncoding / contentMediaType assertions Opt-in1 Opt-in1 Opt-in1

The required-suite row covers every required case for the listed dialect in the official JSON Schema Test Suite. The applicable top-level optional cases are also tested, including arbitrary precision numbers, ECMA-262 regular expressions, anchors, cross-draft references, and dynamic references.

Format assertion support

Formats are annotations by default in each standard dialect. Pass format: true to enable the supported assertions below.2

Format Assertion support
date Supported
time Supported
date-time Supported
duration Supported
email Not supported
idn-email Not supported
hostname Not supported
idn-hostname Not supported
ipv4 Supported
ipv6 Supported
uri Not supported
uri-reference Not supported
iri Not supported
iri-reference Not supported
uuid Supported
uri-template Not supported
json-pointer Supported
relative-json-pointer Supported
regex Not supported

Unsupported and unknown formats remain annotations when assertion is enabled by the caller.

Development

Run the test suite with:

bundle exec rspec

Verify the reviewed official-suite classifications with:

ruby script/oracle-cases --summary

The serialized oracle tools are script/oracle-runner and script/oracle-compare.

Run the linter with:

bundle exec rubocop

AI Disclosure

Large part of this work is generated by OpenAI Codex.

Footnotes

  1. Pass content: true to assert Base64 contentEncoding and JSON contentMediaType. Other encodings and media types remain annotations. ↩ ↩2 ↩3

  2. A custom Draft 2020-12 meta-schema that declares the Format-Assertion vocabulary can assert supported formats without the option and rejects unsupported formats during schema compilation. ↩