From e85adc1ae75e508e6b1976f2a4b8576f0f6377b7 Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Sat, 7 Feb 2026 12:49:28 +0100 Subject: [PATCH] feat: Ruby bindings Signed-off-by: Dmitry Dygalo --- .github/workflows/ci.yml | 118 +- .github/workflows/ruby-release.yml | 125 + Cargo.toml | 5 + LICENSE | 2 +- README.md | 2 +- crates/jsonschema-rb/.gitignore | 4 + crates/jsonschema-rb/.rspec | 3 + crates/jsonschema-rb/.rubocop.yml | 40 + crates/jsonschema-rb/BENCHMARKS.md | 75 + crates/jsonschema-rb/CHANGELOG.md | 6 + crates/jsonschema-rb/Cargo.toml | 28 + crates/jsonschema-rb/Gemfile | 25 + crates/jsonschema-rb/Gemfile.lock | 128 + crates/jsonschema-rb/LICENSE | 21 + crates/jsonschema-rb/MIGRATION.md | 87 + crates/jsonschema-rb/README.md | 489 ++++ crates/jsonschema-rb/Rakefile | 31 + crates/jsonschema-rb/bench/benchmark.rb | 231 ++ .../jsonschema-rb/ext/jsonschema/Cargo.lock | 2062 +++++++++++++++++ .../jsonschema-rb/ext/jsonschema/Cargo.toml | 24 + .../jsonschema-rb/ext/jsonschema/extconf.rb | 23 + crates/jsonschema-rb/extconf.rb | 25 + crates/jsonschema-rb/jsonschema.gemspec | 42 + crates/jsonschema-rb/justfile | 23 + crates/jsonschema-rb/lib/jsonschema.rb | 36 + .../jsonschema-rb/lib/jsonschema/version.rb | 5 + crates/jsonschema-rb/sig/jsonschema.rbs | 385 +++ crates/jsonschema-rb/spec/jsonschema_spec.rb | 1839 +++++++++++++++ crates/jsonschema-rb/spec/readme_spec.rb | 34 + crates/jsonschema-rb/spec/spec_helper.rb | 19 + crates/jsonschema-rb/spec/suite_spec.rb | 113 + crates/jsonschema-rb/src/error_kind.rs | 356 +++ crates/jsonschema-rb/src/evaluation.rs | 116 + crates/jsonschema-rb/src/lib.rs | 1349 +++++++++++ crates/jsonschema-rb/src/options.rs | 1054 +++++++++ crates/jsonschema-rb/src/registry.rs | 138 ++ crates/jsonschema-rb/src/retriever.rs | 100 + crates/jsonschema-rb/src/ser.rs | 724 ++++++ crates/jsonschema-rb/src/static_id.rs | 93 + crates/jsonschema/src/types.rs | 10 +- 40 files changed, 9978 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ruby-release.yml create mode 100644 crates/jsonschema-rb/.gitignore create mode 100644 crates/jsonschema-rb/.rspec create mode 100644 crates/jsonschema-rb/.rubocop.yml create mode 100644 crates/jsonschema-rb/BENCHMARKS.md create mode 100644 crates/jsonschema-rb/CHANGELOG.md create mode 100644 crates/jsonschema-rb/Cargo.toml create mode 100644 crates/jsonschema-rb/Gemfile create mode 100644 crates/jsonschema-rb/Gemfile.lock create mode 100644 crates/jsonschema-rb/LICENSE create mode 100644 crates/jsonschema-rb/MIGRATION.md create mode 100644 crates/jsonschema-rb/README.md create mode 100644 crates/jsonschema-rb/Rakefile create mode 100644 crates/jsonschema-rb/bench/benchmark.rb create mode 100644 crates/jsonschema-rb/ext/jsonschema/Cargo.lock create mode 100644 crates/jsonschema-rb/ext/jsonschema/Cargo.toml create mode 100644 crates/jsonschema-rb/ext/jsonschema/extconf.rb create mode 100644 crates/jsonschema-rb/extconf.rb create mode 100644 crates/jsonschema-rb/jsonschema.gemspec create mode 100644 crates/jsonschema-rb/justfile create mode 100644 crates/jsonschema-rb/lib/jsonschema.rb create mode 100644 crates/jsonschema-rb/lib/jsonschema/version.rb create mode 100644 crates/jsonschema-rb/sig/jsonschema.rbs create mode 100644 crates/jsonschema-rb/spec/jsonschema_spec.rb create mode 100644 crates/jsonschema-rb/spec/readme_spec.rb create mode 100644 crates/jsonschema-rb/spec/spec_helper.rb create mode 100644 crates/jsonschema-rb/spec/suite_spec.rb create mode 100644 crates/jsonschema-rb/src/error_kind.rs create mode 100644 crates/jsonschema-rb/src/evaluation.rs create mode 100644 crates/jsonschema-rb/src/lib.rs create mode 100644 crates/jsonschema-rb/src/options.rs create mode 100644 crates/jsonschema-rb/src/registry.rs create mode 100644 crates/jsonschema-rb/src/retriever.rs create mode 100644 crates/jsonschema-rb/src/ser.rs create mode 100644 crates/jsonschema-rb/src/static_id.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 366b811f7..3209c8929 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: cache-all-crates: "true" key: ${{ matrix.os }} - - run: cargo test --no-fail-fast --all-features --workspace --exclude jsonschema-py + - run: cargo test --no-fail-fast --all-features --workspace --exclude jsonschema-py --exclude jsonschema-rb test-wasm: strategy: @@ -176,6 +176,7 @@ jobs: run: | cargo llvm-cov --no-report --all-features --workspace \ --exclude jsonschema-py \ + --exclude jsonschema-rb \ --exclude benchmark \ --exclude benchmark-suite \ --exclude jsonschema-testsuite \ @@ -301,6 +302,121 @@ jobs: uv pip install crates/jsonschema-py/dist/*.whl uv run --with pytest --with hypothesis pytest crates/jsonschema-py/tests-py + test-ruby: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15] + ruby-version: ['3.2', '3.4', '4.0'] + + name: Test Ruby ${{ matrix.ruby-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - uses: dtolnay/rust-toolchain@stable + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + working-directory: crates/jsonschema-rb + + - run: bundle exec rake compile + working-directory: crates/jsonschema-rb + + - run: bundle exec rspec + working-directory: crates/jsonschema-rb + + test-ruby-gem-install: + name: Ruby Gem Install ${{ matrix.ruby-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15] + ruby-version: ['3.2', '3.4', '4.0'] + + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - uses: dtolnay/rust-toolchain@stable + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: false + + - name: Build gem + run: gem build jsonschema.gemspec + working-directory: crates/jsonschema-rb + + - name: Install rb_sys + run: gem install rb_sys -v '~> 0.9' --no-document + + - name: Install gem + run: | + GEM_FILE=$(ls jsonschema-*.gem | head -1) + gem install "./$GEM_FILE" --local --no-document + working-directory: crates/jsonschema-rb + shell: bash + + - name: Smoke test + run: | + ruby -e " + require 'jsonschema' + raise 'valid? failed' unless JSONSchema.valid?({'type' => 'string'}, 'hello') + raise 'valid? false positive' if JSONSchema.valid?({'type' => 'string'}, 42) + puts 'Gem installation OK' + " + + test-ruby-gem-install-musl: + name: Ruby Gem Install (Alpine/musl) + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - name: Test gem install on Alpine/musl + run: | + docker run --rm \ + -v ${{ github.workspace }}:/workspace \ + -w /workspace \ + public.ecr.aws/docker/library/ruby:3.4-alpine3.21 \ + sh -c ' + set -e + apk add --no-cache alpine-sdk curl gcompat clang clang-dev llvm-dev + curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . ~/.cargo/env + gem update --system + cd crates/jsonschema-rb + gem install rb_sys -v "~> 0.9" --no-document + gem build jsonschema.gemspec + gem install jsonschema-*.gem --local --no-document + ruby -e "require \"jsonschema\"; raise unless JSONSchema.valid?({\"type\" => \"string\"}, \"hello\"); puts \"musl OK\"" + ' + + lint-ruby: + name: Lint Ruby + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + working-directory: crates/jsonschema-rb + + - run: bundle exec rubocop + working-directory: crates/jsonschema-rb + lint-fmt: name: Lint formatting runs-on: ubuntu-24.04 diff --git a/.github/workflows/ruby-release.yml b/.github/workflows/ruby-release.yml new file mode 100644 index 000000000..a68aadfb7 --- /dev/null +++ b/.github/workflows/ruby-release.yml @@ -0,0 +1,125 @@ +name: Ruby Release + +on: + push: + tags: + - ruby-v* + workflow_dispatch: + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci-data: + runs-on: ubuntu-24.04 + outputs: + supported-ruby-platforms: ${{ steps.parse.outputs.supported-ruby-platforms }} + stable-ruby-versions: ${{ steps.parse.outputs.stable-ruby-versions }} + steps: + - id: fetch + uses: oxidize-rb/actions/fetch-ci-data@v1 + with: + supported-ruby-platforms: | + exclude: + - arm-linux + - x64-mingw32 + - aarch64-mingw-ucrt + stable-ruby-versions: | + exclude: + - head + - id: parse + run: | + echo "supported-ruby-platforms=$(echo '${{ steps.fetch.outputs.result }}' | jq -c '."supported-ruby-platforms"')" >> "$GITHUB_OUTPUT" + echo "stable-ruby-versions=$(echo '${{ steps.fetch.outputs.result }}' | jq -r '."stable-ruby-versions" | join(",")')" >> "$GITHUB_OUTPUT" + + build: + name: Build (${{ matrix.platform }}) + runs-on: ubuntu-24.04 + needs: ci-data + strategy: + fail-fast: false + matrix: + platform: ${{ fromJSON(needs.ci-data.outputs.supported-ruby-platforms) }} + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + + - uses: oxidize-rb/actions/cross-gem@v1 + id: cross-gem + with: + platform: ${{ matrix.platform }} + ruby-versions: ${{ needs.ci-data.outputs.stable-ruby-versions }} + working-directory: crates/jsonschema-rb + + - name: Smoke test (x86_64-linux only) + if: matrix.platform == 'x86_64-linux' + run: | + gem install pkg/*.gem --local + ruby -e "require 'jsonschema'; raise 'validation failed' unless JSONSchema.valid?({'type'=>'string'}, 'hello'); puts 'smoke test OK'" + working-directory: crates/jsonschema-rb + + - uses: actions/upload-artifact@v6 + with: + name: gem-${{ matrix.platform }} + path: crates/jsonschema-rb/pkg/*.gem + if-no-files-found: error + + release: + name: Release + runs-on: ubuntu-24.04 + needs: build + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + + - uses: actions/download-artifact@v7 + with: + path: artifacts + pattern: gem-* + + - name: Build source gem + run: gem build jsonschema.gemspec + working-directory: crates/jsonschema-rb + + - name: Collect all gems + run: | + mkdir -p pkg + mv artifacts/**/*.gem pkg/ + mv crates/jsonschema-rb/jsonschema-*.gem pkg/ + + - name: Push to RubyGems + env: + RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: | + mkdir -p ~/.gem + echo "---" > ~/.gem/credentials + echo ":rubygems_api_key: ${RUBYGEMS_API_KEY}" >> ~/.gem/credentials + chmod 0600 ~/.gem/credentials + for gem in pkg/*.gem; do + echo "Pushing ${gem}..." + gem push "${gem}" + done + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: pkg/*.gem + generate_release_notes: true diff --git a/Cargo.toml b/Cargo.toml index e63d2b959..5f3cfe20f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,11 @@ rest_pat_in_fully_bound_structs = "warn" files.extend-exclude = ["*.json"] default.extend-ignore-re = ["propert"] +[patch.crates-io] +# Keep workspace crates (including language bindings) on the local core crate +# while still using a versioned dependency declaration in sub-crates. +jsonschema = { path = "crates/jsonschema" } + [profile.release] lto = "fat" codegen-units = 1 diff --git a/LICENSE b/LICENSE index 7b09ade00..20c070f64 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020-2025 Dmitry Dygalo +Copyright (c) 2020-2026 Dmitry Dygalo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5c4c0e74c..3431c9680 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ See more usage examples in the [documentation](https://docs.rs/jsonschema). - ๐ŸŒ Blocking & non-blocking remote reference fetching (network/file) - ๐ŸŽจ Structured Output v1 reports (flag/list/hierarchical) - โœจ Meta-schema validation for schema documents, including custom metaschemas -- ๐Ÿ”— Bindings for [Python](https://github.com/Stranger6667/jsonschema/tree/master/crates/jsonschema-py) +- ๐Ÿ”— Bindings for [Python](https://github.com/Stranger6667/jsonschema/tree/master/crates/jsonschema-py) and [Ruby](https://github.com/Stranger6667/jsonschema/tree/master/crates/jsonschema-rb) - ๐Ÿš€ WebAssembly support - ๐Ÿ’ป Command Line Interface diff --git a/crates/jsonschema-rb/.gitignore b/crates/jsonschema-rb/.gitignore new file mode 100644 index 000000000..6cc7b82fa --- /dev/null +++ b/crates/jsonschema-rb/.gitignore @@ -0,0 +1,4 @@ +tmp/ +vendor/ +*.so +*.bundle diff --git a/crates/jsonschema-rb/.rspec b/crates/jsonschema-rb/.rspec new file mode 100644 index 000000000..7a2cc1a6e --- /dev/null +++ b/crates/jsonschema-rb/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color diff --git a/crates/jsonschema-rb/.rubocop.yml b/crates/jsonschema-rb/.rubocop.yml new file mode 100644 index 000000000..62d358e3d --- /dev/null +++ b/crates/jsonschema-rb/.rubocop.yml @@ -0,0 +1,40 @@ +inherit_mode: + merge: + - Exclude + +AllCops: + TargetRubyVersion: 3.2 + NewCops: enable + SuggestExtensions: false + Exclude: + - "vendor/**/*" + +# Keep line length relaxed for developer ergonomics and error-message expectations. +Layout/LineLength: + Max: 180 + AllowURI: true + Exclude: + - "spec/**/*" + +Layout/CaseIndentation: + EnforcedStyle: case + +Layout/HashAlignment: + EnforcedHashRocketStyle: key + EnforcedColonStyle: key + EnforcedLastArgumentHashStyle: always_inspect + +Gemspec/DeprecatedAttributeAssignment: + Enabled: true + +Style/Documentation: + Enabled: false + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Metrics/BlockLength: + Enabled: false + +Metrics/ParameterLists: + Enabled: false diff --git a/crates/jsonschema-rb/BENCHMARKS.md b/crates/jsonschema-rb/BENCHMARKS.md new file mode 100644 index 000000000..086707165 --- /dev/null +++ b/crates/jsonschema-rb/BENCHMARKS.md @@ -0,0 +1,75 @@ +# Benchmark Suite + +A benchmarking suite for comparing different Ruby JSON Schema implementations. + +## Implementations + +- `jsonschema` (latest version in this repo) +- [json_schemer](https://rubygems.org/gems/json_schemer) (v2.5.0) +- [json-schema](https://rubygems.org/gems/json-schema) (v6.1.0) +- [rj_schema](https://rubygems.org/gems/rj_schema) (v1.0.5) - RapidJSON-based (C++) + +## Usage + +Install the dependencies: + +```console +$ bundle install --with benchmark +``` + +Run the benchmarks: + +```console +$ bundle exec ruby bench/benchmark.rb +``` + +## Overview + +| Benchmark | Description | Schema Size | Instance Size | +|-----------|------------------------------------------------|-------------|---------------| +| OpenAPI | Zuora API validated against OpenAPI 3.0 schema | 18 KB | 4.5 MB | +| Swagger | Kubernetes API (v1.10.0) with Swagger schema | 25 KB | 3.0 MB | +| GeoJSON | Canadian border in GeoJSON format | 4.8 KB | 2.1 MB | +| CITM | Concert data catalog with inferred schema | 2.3 KB | 501 KB | +| Fast | From fastjsonschema benchmarks (valid/invalid) | 595 B | 55 B / 60 B | +| FHIR | Patient example validated against FHIR schema | 3.3 MB | 2.1 KB | +| Recursive | Nested data with `$dynamicRef` | 1.4 KB | 449 B | + +Sources: +- OpenAPI: [Zuora](https://github.com/APIs-guru/openapi-directory/blob/1afd351ddf50e050acdb52937a819ef1927f417a/APIs/zuora.com/2021-04-23/openapi.yaml), [Schema](https://spec.openapis.org/oas/3.0/schema/2021-09-28) +- Swagger: [Kubernetes](https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/kubernetes.io/v1.10.0/swagger.yaml), [Schema](https://github.com/OAI/OpenAPI-Specification/blob/main/_archive_/schemas/v2.0/schema.json) +- GeoJSON: [Schema](https://geojson.org/schema/FeatureCollection.json) +- CITM: Schema inferred via [infers-jsonschema](https://github.com/Stranger6667/infers-jsonschema) +- Fast: [fastjsonschema benchmarks](https://github.com/horejsek/python-fastjsonschema/blob/master/performance.py#L15) +- FHIR: [Schema](http://hl7.org/fhir/R4/fhir.schema.json.zip) (R4 v4.0.1), [Example](http://hl7.org/fhir/R4/patient-example-d.json.html) + +## Results + +### Comparison with Other Libraries + +| Benchmark | json-schema | rj_schema | json_schemer | jsonschema (validate) | +|------------------|--------------------------|--------------------------------|--------------------------------|-----------------------| +| OpenAPI | 2.37 s (**x174.36**) | 380.78 ms (**x28.07**) | 406.75 ms (**x29.98**) | 13.57 ms | +| Swagger | 4.02 s (**x534.56**) | - (4) | - (2) | 7.52 ms | +| Canada (GeoJSON) | - (1) | 74.83 ms (**x9.80**) | 1.07 s (**x140.50**) | 7.63 ms | +| CITM Catalog | - (1) | 17.25 ms (**x6.56**) | 67.85 ms (**x25.79**) | 2.63 ms | +| Fast (Valid) | - (1) | 68.04 ยตs (**x125.06**) | 30.21 ยตs (**x55.53**) | 544.03 ns | +| Fast (Invalid) | - (1) | - (3) | 29.83 ยตs (**x67.58**) | 441.40 ns | +| FHIR | 403.60 ms (**x75105.32**)| 2.10 s (**x391159.68**) | 8.44 ms (**x1571.47**) | 5.37 ยตs | +| Recursive | - (1) | 3.15 ms (**x224.38**) | 21.25 s (**x1513937.35**) | 14.04 ยตs | + +Notes: + +1. `json-schema` does not support Draft 7 schemas. + +2. `json_schemer` fails to resolve the Draft 4 meta-schema reference in the Swagger schema. + +3. `rj_schema` uses Draft 4 semantics for `exclusiveMaximum` (boolean, not number), producing incorrect results for this Draft 7 schema. + +4. `rj_schema` fails to resolve the Draft 4 meta-schema `$ref` in the Swagger schema. + +You can find benchmark code in [bench/](bench/), Ruby version `4.0.1`, Rust version `1.92`. + +## Contributing + +Contributions to improve, expand, or optimize the benchmark suite are welcome. This includes adding new benchmarks, ensuring fair representation of real-world use cases, and optimizing the configuration and usage of benchmarked libraries. Such efforts are highly appreciated as they ensure accurate and meaningful performance comparisons. diff --git a/crates/jsonschema-rb/CHANGELOG.md b/crates/jsonschema-rb/CHANGELOG.md new file mode 100644 index 000000000..79310ae2c --- /dev/null +++ b/crates/jsonschema-rb/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## [Unreleased] + +- Initial public release + diff --git a/crates/jsonschema-rb/Cargo.toml b/crates/jsonschema-rb/Cargo.toml new file mode 100644 index 000000000..3ce9e88ea --- /dev/null +++ b/crates/jsonschema-rb/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "jsonschema-rb" +version = "0.41.0" +edition = "2021" +authors = ["Dmitry Dygalo "] +license = "MIT" +description = "A high-performance JSON Schema validator for Ruby" +readme = "README.md" +repository = "https://github.com/Stranger6667/jsonschema" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +jsonschema = { version = "0.41.0", default-features = false, features = [ + "arbitrary-precision", + "resolve-file", + "resolve-http", +] } +magnus = { version = "0.8", features = ["rb-sys"] } +rb-sys = "0.9" +serde = { workspace = true } +serde_json = { workspace = true, features = ["arbitrary_precision"] } +referencing = { version = "0.41.0", path = "../jsonschema-referencing" } + +[lints] +workspace = true diff --git a/crates/jsonschema-rb/Gemfile b/crates/jsonschema-rb/Gemfile new file mode 100644 index 000000000..fae591b4c --- /dev/null +++ b/crates/jsonschema-rb/Gemfile @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +group :development do + gem "rake", "~> 13.0" + gem "rake-compiler", "~> 1.2" + gem "rb_sys", "~> 0.9" +end + +group :test do + gem "rspec", "~> 3.12" +end + +group :lint do + gem "rubocop", "~> 1.75" +end + +group :benchmark do + gem "json-schema", "~> 6.1" + gem "json_schemer", "~> 2.5" + gem "rj_schema", "~> 1.0" +end diff --git a/crates/jsonschema-rb/Gemfile.lock b/crates/jsonschema-rb/Gemfile.lock new file mode 100644 index 000000000..4a86063a7 --- /dev/null +++ b/crates/jsonschema-rb/Gemfile.lock @@ -0,0 +1,128 @@ +PATH + remote: . + specs: + jsonschema (0.41.0) + rb_sys (~> 0.9.124) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + bigdecimal (4.0.1) + diff-lcs (1.6.2) + hana (1.3.7) + json (2.18.1) + json-schema (6.1.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (1.27.0) + parser (3.3.10.1) + ast (~> 2.4.1) + racc + prism (1.9.0) + public_suffix (7.0.2) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + rake-compiler (1.3.1) + rake + rake-compiler-dock (1.11.0) + rb_sys (0.9.124) + rake-compiler-dock (= 1.11.0) + regexp_parser (2.11.3) + rj_schema (1.0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.84.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + simpleidn (0.2.3) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + json-schema (~> 6.1) + json_schemer (~> 2.5) + jsonschema! + rake (~> 13.0) + rake-compiler (~> 1.2) + rb_sys (~> 0.9) + rj_schema (~> 1.0) + rspec (~> 3.12) + rubocop (~> 1.75) + +CHECKSUMS + addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d + json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986 + json-schema (6.1.0) sha256=6bf70a2cfb6dfd5a06da28093fa8190f324c88eabd36a7f47097f227321dc702 + json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396 + jsonschema (0.41.0) + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rake-compiler (1.3.1) sha256=6b351612b6e2d73ddd5563ee799bb58685176e05363db6758504bd11573d670a + rake-compiler-dock (1.11.0) sha256=eab51f2cd533eb35cea6b624a75281f047123e70a64c58b607471bb49428f8c2 + rb_sys (0.9.124) sha256=513476557b12eaf73764b3da9f8746024558fe8699bda785fb548c9aa3877ae7 + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + rj_schema (1.0.5) sha256=8ad0b516435626456c0de944a73bcb664d16eedf9394ecc0ca1f58dd08d5b242 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.84.1) sha256=14cc626f355141f5a2ef53c10a68d66b13bb30639b26370a76559096cc6bcc1a + rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29 + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + +BUNDLED WITH + 4.0.3 diff --git a/crates/jsonschema-rb/LICENSE b/crates/jsonschema-rb/LICENSE new file mode 100644 index 000000000..20c070f64 --- /dev/null +++ b/crates/jsonschema-rb/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2026 Dmitry Dygalo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/jsonschema-rb/MIGRATION.md b/crates/jsonschema-rb/MIGRATION.md new file mode 100644 index 000000000..8af170ba0 --- /dev/null +++ b/crates/jsonschema-rb/MIGRATION.md @@ -0,0 +1,87 @@ +# Migrating from json_schemer + +## Quick Reference + +| json_schemer | jsonschema | +|---|---| +| `JSONSchemer.schema(s)` | `JSONSchema.validator_for(s)` | +| `JSONSchemer.schema(s, meta_schema: 'draft7')` | `JSONSchema::Draft7Validator.new(s)` | +| `schemer.valid?(d)` | `validator.valid?(d)` | +| `schemer.validate(d)` | `validator.each_error(d)` | +| `schemer.validate!(d)` | `validator.validate!(d)` | +| `JSONSchemer.valid?(s, d)` | `JSONSchema.valid?(s, d)` | +| `JSONSchemer.valid_schema?(s)` | `JSONSchema::Meta.valid?(s)` | +| `error["data_pointer"]` | `error.instance_path_pointer` | +| `error["schema_pointer"]` | `error.schema_path_pointer` | +| `error["type"]` | `error.kind.name` | +| `error["error"]` | `error.message` | +| `ref_resolver: proc` | `retriever: proc` | +| `format: true` | `validate_formats: true` | + +Draft-specific validators are also available: `JSONSchema::Draft7Validator.new(schema)` + +## What Stays the Same + +- JSON Schema documents work as-is +- `valid?` and `validate!` method names +- Custom format validators via `formats:` with the same proc syntax +- One-off validation: `JSONSchema.valid?(schema, data)` + +## Error Objects + +json_schemer returns hashes, jsonschema returns `ValidationError` objects: + +```ruby +# json_schemer +error["data_pointer"] # => "/foo/bar" +error["schema_pointer"] # => "/properties/foo/minimum" +error["type"] # => "minimum" +error["error"] # => "value is less than 10" + +# jsonschema +error.instance_path # => ["foo", "bar"] +error.instance_path_pointer # => "/foo/bar" (same format as data_pointer) +error.schema_path # => ["properties", "foo", "minimum"] +error.schema_path_pointer # => "/properties/foo/minimum" +error.kind.name # => "minimum" +error.message # => "value is less than 10" + +# Need a hash? Use to_h on error kind +error.kind.to_h # => { "name" => "minimum", "value" => { "limit" => 10 } } +``` + +## Reference Resolution + +```ruby +# json_schemer +JSONSchemer.schema(schema, ref_resolver: refs.to_proc) + +# jsonschema โ€” retriever +JSONSchema.validator_for(schema, retriever: ->(uri) { fetch_schema(uri) }) + +# jsonschema โ€” registry +registry = JSONSchema::Registry.new([["http://example.com/s", sub_schema]]) +JSONSchema.validator_for(schema, registry: registry) +``` + +## What You Gain + +- **Structured output** โ€” `evaluate` API with flag, list, and hierarchical output formats +- **Custom keywords** โ€” extend JSON Schema with domain-specific validation rules +- **Error masking** โ€” hide sensitive data in error messages with `mask:` +- **Regex engine configuration** โ€” choose between fancy-regex (default) and linear-time regex +- **Email validation options** โ€” fine-grained control over email format validation + +## Not Supported + +- `insert_property_defaults` โ€” jsonschema is a validator, not a data transformer +- OpenAPI document parsing โ€” use a dedicated OpenAPI library + +## Migration Checklist + +- [ ] Replace `JSONSchemer.schema` with `JSONSchema.validator_for` +- [ ] Replace `validate` (error iteration) with `each_error` +- [ ] Replace `ref_resolver:` with `retriever:` or use `Registry` +- [ ] Replace `format: true` with `validate_formats: true` +- [ ] Replace `meta_schema: 'draft7'` with `JSONSchema::Draft7Validator.new(s)` or `draft: :draft7` on one-off functions +- [ ] Update error handling from hash access to `ValidationError` attributes diff --git a/crates/jsonschema-rb/README.md b/crates/jsonschema-rb/README.md new file mode 100644 index 000000000..233e08d2e --- /dev/null +++ b/crates/jsonschema-rb/README.md @@ -0,0 +1,489 @@ +# jsonschema + +[![Build](https://img.shields.io/github/actions/workflow/status/Stranger6667/jsonschema/ci.yml?branch=master&style=flat-square)](https://github.com/Stranger6667/jsonschema/actions) +[![Version](https://img.shields.io/gem/v/jsonschema.svg?style=flat-square)](https://rubygems.org/gems/jsonschema) +[![Ruby versions](https://img.shields.io/badge/ruby-3.2%20%7C%203.4%20%7C%204.0-blue?style=flat-square)](https://rubygems.org/gems/jsonschema) +[Supported Dialects](https://bowtie.report/#/implementations/rust-jsonschema) + +A high-performance JSON Schema validator for Ruby. + +```ruby +require 'jsonschema' + +schema = { "maxLength" => 5 } +instance = "foo" + +# One-off validation +JSONSchema.valid?(schema, instance) # => true + +begin + JSONSchema.validate!(schema, "incorrect") +rescue JSONSchema::ValidationError => e + puts e.message # => "\"incorrect\" is longer than 5 characters" +end + +# Build & reuse (faster) +validator = JSONSchema.validator_for(schema) + +# Iterate over errors +validator.each_error(instance) do |error| + puts "Error: #{error.message}" + puts "Location: #{error.instance_path}" +end + +# Boolean result +validator.valid?(instance) # => true + +# Structured output (JSON Schema Output v1) +evaluation = validator.evaluate(instance) +evaluation.annotations.each do |ann| + puts "Annotation at #{ann[:schemaLocation]}: #{ann[:annotations]}" +end +``` + +> **Migrating from `json_schemer`?** See the [migration guide](MIGRATION.md). + +## Highlights + +- ๐Ÿ“š Full support for popular JSON Schema drafts +- ๐ŸŒ Remote reference fetching (network/file) +- ๐Ÿ”ง Custom keywords and format validators +- โœจ Meta-schema validation for schema documents +- โ™ฆ๏ธ Supports Ruby 3.2, 3.4 and 4.0 + +### Supported drafts + +The following drafts are supported: + +- [![Draft 2020-12](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Frust-jsonschema%2Fcompliance%2Fdraft2020-12.json)](https://bowtie.report/#/implementations/rust-jsonschema) +- [![Draft 2019-09](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Frust-jsonschema%2Fcompliance%2Fdraft2019-09.json)](https://bowtie.report/#/implementations/rust-jsonschema) +- [![Draft 7](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Frust-jsonschema%2Fcompliance%2Fdraft7.json)](https://bowtie.report/#/implementations/rust-jsonschema) +- [![Draft 6](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Frust-jsonschema%2Fcompliance%2Fdraft6.json)](https://bowtie.report/#/implementations/rust-jsonschema) +- [![Draft 4](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Frust-jsonschema%2Fcompliance%2Fdraft4.json)](https://bowtie.report/#/implementations/rust-jsonschema) + +You can check the current status on the [Bowtie Report](https://bowtie.report/#/implementations/rust-jsonschema). + +## Installation + +Add to your Gemfile: + +```ruby +gem 'jsonschema' +``` + +Pre-built native gems are available for: + +- **Linux**: `x86_64`, `aarch64` (glibc and musl) +- **macOS**: `x86_64`, `arm64` +- **Windows**: `x64` (mingw-ucrt) + +If no pre-built gem is available for your platform, it will be compiled from source during installation. You'll need: +- Ruby 3.2+ +- Rust toolchain ([rustup](https://rustup.rs/)) + +## Usage + +### Reusable validators + +For validating multiple instances against the same schema, create a reusable validator. +`validator_for` automatically detects the draft version from the `$schema` keyword in the schema: + +```ruby +validator = JSONSchema.validator_for({ + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "age" => { "type" => "integer", "minimum" => 0 } + }, + "required" => ["name"] +}) + +validator.valid?({ "name" => "Alice", "age" => 30 }) # => true +validator.valid?({ "age" => 30 }) # => false +``` + +You can use draft-specific validators for different JSON Schema versions: + +```ruby +validator = JSONSchema::Draft7Validator.new(schema) + +# Available: Draft4Validator, Draft6Validator, Draft7Validator, +# Draft201909Validator, Draft202012Validator +``` + +### Custom format validators + +```ruby +phone_format = ->(value) { value.match?(/^\+?[1-9]\d{1,14}$/) } + +validator = JSONSchema.validator_for( + { "type" => "string", "format" => "phone" }, + validate_formats: true, + formats: { "phone" => phone_format } +) +``` + +### Custom keyword validators + +```ruby +class EvenValidator + def initialize(parent_schema, value, schema_path) + @enabled = value + end + + def validate(instance) + return unless @enabled && instance.is_a?(Integer) + raise "#{instance} is not even" if instance.odd? + end +end + +validator = JSONSchema.validator_for( + { "type" => "integer", "even" => true }, + keywords: { "even" => EvenValidator } +) +``` + +Each custom keyword class must implement: +- `initialize(parent_schema, value, schema_path)` - called during schema compilation +- `validate(instance)` - raise on failure, return normally on success + +### Structured evaluation output + +When you need more than a boolean result, use the `evaluate` API to access the JSON Schema Output v1 formats: + +```ruby +schema = { + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "age" => { "type" => "integer" } + }, + "required" => ["name"] +} +validator = JSONSchema.validator_for(schema) + +evaluation = validator.evaluate({ "age" => "not_an_integer" }) + +evaluation.valid? # => false + +# Flag output (simplest) +evaluation.flag +# => { "valid" => false } + +# List output (flat) +evaluation.list +# => { "valid" => false, "details" => [ +# { "valid" => false, "evaluationPath" => "", "instanceLocation" => "", "schemaLocation" => "" }, +# { "valid" => false, "evaluationPath" => "/required", "instanceLocation" => "", "schemaLocation" => "/required", +# "errors" => { "required" => "\"name\" is a required property" } }, +# ... +# ] } + +# Hierarchical output (nested tree following schema structure) +evaluation.hierarchical +# => { "valid" => false, "evaluationPath" => "", "instanceLocation" => "", "schemaLocation" => "", +# "details" => [ ... ] } + +# Collected errors across all nodes +evaluation.errors +# => [{ "schemaLocation" => "/required", "instanceLocation" => "", +# "absoluteKeywordLocation" => nil, "error" => "\"name\" is a required property" }, ...] + +# Collected annotations +evaluation.annotations +# => [{ "schemaLocation" => "/properties", "instanceLocation" => "", +# "absoluteKeywordLocation" => nil, "annotations" => ["age"] }] +``` + +## Meta-Schema Validation + +Validate that a JSON Schema document is itself valid: + +```ruby +JSONSchema::Meta.valid?({ "type" => "string" }) # => true +JSONSchema::Meta.valid?({ "type" => "invalid_type" }) # => false + +begin + JSONSchema::Meta.validate!({ "type" => 123 }) +rescue JSONSchema::ValidationError => e + e.message # => "123 is not valid under any of the schemas listed in the 'anyOf' keyword" +end +``` + +## External References + +By default, `jsonschema` resolves HTTP references and file references from the local file system. You can implement a custom retriever to handle external references: + +```ruby +schemas = { + "https://example.com/person.json" => { + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "age" => { "type" => "integer" } + }, + "required" => ["name", "age"] + } +} + +retriever = ->(uri) { schemas[uri] } + +schema = { "$ref" => "https://example.com/person.json" } +validator = JSONSchema.validator_for(schema, retriever: retriever) + +validator.valid?({ "name" => "Alice", "age" => 30 }) # => true +validator.valid?({ "name" => "Bob" }) # => false (missing "age") +``` + +## Schema Registry + +For applications that frequently use the same schemas, create a registry to store and reference them: + +```ruby +registry = JSONSchema::Registry.new([ + ["https://example.com/address.json", { + "type" => "object", + "properties" => { + "street" => { "type" => "string" }, + "city" => { "type" => "string" } + } + }], + ["https://example.com/person.json", { + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "address" => { "$ref" => "https://example.com/address.json" } + } + }] +]) + +validator = JSONSchema.validator_for( + { "$ref" => "https://example.com/person.json" }, + registry: registry +) + +validator.valid?({ + "name" => "John", + "address" => { "street" => "Main St", "city" => "Boston" } +}) # => true +``` + +The registry also accepts `draft:` and `retriever:` options: + +```ruby +registry = JSONSchema::Registry.new( + [["https://example.com/person.json", schemas["https://example.com/person.json"]]], + draft: :draft7, + retriever: retriever +) +``` + +## Regular Expression Configuration + +When validating schemas with regex patterns (in `pattern` or `patternProperties`), you can configure the underlying regex engine: + +```ruby +# Default fancy-regex engine with backtracking limits +# (supports lookaround and backreferences but needs protection against DoS) +validator = JSONSchema.validator_for( + { "type" => "string", "pattern" => "^(a+)+$" }, + pattern_options: JSONSchema::FancyRegexOptions.new(backtrack_limit: 10_000) +) + +# Standard regex engine for guaranteed linear-time matching +# (prevents regex DoS attacks but supports fewer features) +validator = JSONSchema.validator_for( + { "type" => "string", "pattern" => "^a+$" }, + pattern_options: JSONSchema::RegexOptions.new +) + +# Both engines support memory usage configuration +validator = JSONSchema.validator_for( + { "type" => "string", "pattern" => "^a+$" }, + pattern_options: JSONSchema::RegexOptions.new( + size_limit: 1024 * 1024, # Maximum compiled pattern size + dfa_size_limit: 10240 # Maximum DFA cache size + ) +) +``` + +The available options: + + - `FancyRegexOptions`: Default engine with lookaround and backreferences support + + - `backtrack_limit`: Maximum backtracking steps + - `size_limit`: Maximum compiled regex size in bytes + - `dfa_size_limit`: Maximum DFA cache size in bytes + + - `RegexOptions`: Safer engine with linear-time guarantee + + - `size_limit`: Maximum compiled regex size in bytes + - `dfa_size_limit`: Maximum DFA cache size in bytes + +This configuration is crucial when working with untrusted schemas where attackers might craft malicious regex patterns. + +## Email Format Configuration + +When validating email addresses using `{"format": "email"}`, you can customize the validation behavior: + +```ruby +# Require a top-level domain (reject "user@localhost") +validator = JSONSchema.validator_for( + { "format" => "email", "type" => "string" }, + validate_formats: true, + email_options: JSONSchema::EmailOptions.new(require_tld: true) +) +validator.valid?("user@localhost") # => false +validator.valid?("user@example.com") # => true + +# Disallow IP address literals and display names +validator = JSONSchema.validator_for( + { "format" => "email", "type" => "string" }, + validate_formats: true, + email_options: JSONSchema::EmailOptions.new( + allow_domain_literal: false, # Reject "user@[127.0.0.1]" + allow_display_text: false # Reject "Name " + ) +) + +# Require minimum domain segments +validator = JSONSchema.validator_for( + { "format" => "email", "type" => "string" }, + validate_formats: true, + email_options: JSONSchema::EmailOptions.new(minimum_sub_domains: 3) # e.g., user@sub.example.com +) +``` + +Available options: + + - `require_tld`: Require a top-level domain (e.g., reject "user@localhost") + - `allow_domain_literal`: Allow IP address literals like "user@[127.0.0.1]" (default: true) + - `allow_display_text`: Allow display names like "Name " (default: true) + - `minimum_sub_domains`: Minimum number of domain segments required + +## Error Handling + +`jsonschema` provides detailed validation errors through the `ValidationError` class: + +```ruby +schema = { "type" => "string", "maxLength" => 5 } + +begin + JSONSchema.validate!(schema, "too long") +rescue JSONSchema::ValidationError => error + # Basic error information + error.message # => '"too long" is longer than 5 characters' + error.verbose_message # => Full context with schema path and instance + error.instance_path # => Location in the instance that failed + error.schema_path # => Location in the schema that failed + + # Detailed error information via `kind` + error.kind.name # => "maxLength" + error.kind.value # => { "limit" => 5 } + error.kind.to_h # => { "name" => "maxLength", "value" => { "limit" => 5 } } +end +``` + +### Error Kind Properties + +Each error has a `kind` property with convenient accessors: + +```ruby +JSONSchema.each_error({ "minimum" => 5 }, 3).each do |error| + error.kind.name # => "minimum" + error.kind.value # => { "limit" => 5 } + error.kind.to_h # => { "name" => "minimum", "value" => { "limit" => 5 } } + error.kind.to_s # => "minimum" +end +``` + +### Error Message Masking + +When working with sensitive data, you can mask instance values in error messages: + +```ruby +schema = { + "type" => "object", + "properties" => { + "password" => { "type" => "string", "minLength" => 8 }, + "api_key" => { "type" => "string", "pattern" => "^[A-Z0-9]{32}$" } + } +} + +validator = JSONSchema.validator_for(schema, mask: "[REDACTED]") + +begin + validator.validate!({ "password" => "123", "api_key" => "secret_key_123" }) +rescue JSONSchema::ValidationError => exc + puts exc.message + # => '[REDACTED] does not match "^[A-Z0-9]{32}$"' + puts exc.verbose_message + # => '[REDACTED] does not match "^[A-Z0-9]{32}$"\n\nFailed validating...\nOn instance["api_key"]:\n [REDACTED]' +end +``` + +### Exception Classes + +- **`JSONSchema::ValidationError`** - raised on validation failure + - `message`, `verbose_message`, `instance_path`, `schema_path`, `evaluation_path`, `kind`, `instance` + - JSON Pointer helpers: `instance_path_pointer`, `schema_path_pointer`, `evaluation_path_pointer` +- **`JSONSchema::ReferencingError`** - raised when `$ref` cannot be resolved + +## Options Reference + +One-off validation methods (`valid?`, `validate!`, `each_error`, `evaluate`) accept these keyword arguments: + +```ruby +JSONSchema.valid?(schema, instance, + draft: :draft7, # Specific draft version (symbol) + validate_formats: true, # Enable format validation (default: false) + ignore_unknown_formats: true, # Don't error on unknown formats (default: true) + base_uri: "https://example.com", # Base URI for reference resolution + mask: "[REDACTED]", # Mask sensitive data in error messages + retriever: ->(uri) { ... }, # Custom schema retriever for $ref + formats: { "name" => proc }, # Custom format validators + keywords: { "name" => Klass }, # Custom keyword validators + registry: registry, # Pre-registered schemas + pattern_options: opts, # RegexOptions or FancyRegexOptions + email_options: opts, # EmailOptions + http_options: opts # HttpOptions +) +``` + +`evaluate` accepts the same options except `mask` (currently unsupported for evaluation output). + +`validator_for` accepts the same options except `draft:` โ€” use draft-specific validators (`Draft7Validator.new`, etc.) to pin a draft version. + +Valid draft symbols: `:draft4`, `:draft6`, `:draft7`, `:draft201909`, `:draft202012`. + +## Performance + +`jsonschema` is designed for high performance, outperforming other Ruby JSON Schema validators in most scenarios: + +- **26-141x** faster than `json_schemer` for complex schemas and large instances +- **174-535x** faster than `json-schema` where supported +- **7-125x** faster than `rj_schema` (RapidJSON/C++) + +For detailed benchmarks, see our [full performance comparison](https://github.com/Stranger6667/jsonschema/blob/master/crates/jsonschema-rb/BENCHMARKS.md). + +**Tips:** Reuse validators. Use `valid?` for boolean checks (short-circuits on first error). + +## Acknowledgements + +This library draws API design inspiration from the Python [`jsonschema`](https://github.com/python-jsonschema/jsonschema) package. We're grateful to the Python `jsonschema` maintainers and contributors for their pioneering work in JSON Schema validation. + +## Support + +If you have questions, need help, or want to suggest improvements, please use [GitHub Discussions](https://github.com/Stranger6667/jsonschema/discussions). + +## Sponsorship + +If you find `jsonschema` useful, please consider [sponsoring its development](https://github.com/sponsors/Stranger6667). + +## Contributing + +See [CONTRIBUTING.md](https://github.com/Stranger6667/jsonschema/blob/master/CONTRIBUTING.md) for details. + +## License + +Licensed under [MIT License](https://github.com/Stranger6667/jsonschema/blob/master/LICENSE). diff --git a/crates/jsonschema-rb/Rakefile b/crates/jsonschema-rb/Rakefile new file mode 100644 index 000000000..541bb7087 --- /dev/null +++ b/crates/jsonschema-rb/Rakefile @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rb_sys/extensiontask" + +begin + require "rspec/core/rake_task" + RSpec::Core::RakeTask.new(:spec) +rescue LoadError # rubocop:disable Lint/SuppressedException +end + +GEMSPEC = Gem::Specification.load("jsonschema.gemspec") + +# Use jsonschema-rb as the crate name to match Cargo.toml package name +# This prevents rb_sys from finding the wrong crate in the workspace +RbSys::ExtensionTask.new("jsonschema-rb", GEMSPEC) do |ext| + ext.lib_dir = "lib/jsonschema" + ext.cross_compile = true + ext.cross_platform = %w[ + x86_64-linux + x86_64-linux-musl + aarch64-linux + aarch64-linux-musl + arm64-darwin + x86_64-darwin + x64-mingw-ucrt + ] +end + +task build: :compile +task default: %i[compile spec] diff --git a/crates/jsonschema-rb/bench/benchmark.rb b/crates/jsonschema-rb/bench/benchmark.rb new file mode 100644 index 000000000..f2b0362ba --- /dev/null +++ b/crates/jsonschema-rb/bench/benchmark.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require "json" +require "jsonschema" +require "json_schemer" +require "json-schema" +require "rj_schema" + +BENCHMARK_DATA_PATH = File.expand_path("../../benchmark/data", __dir__) + +# Reusable helpers for section printing, time formatting, and measurement +module BenchHelper + SEPARATOR = ("=" * 70).freeze + SUB_SEPARATOR = ("-" * 70).freeze + + def self.section(title) + puts SEPARATOR + puts title + puts SEPARATOR + puts + end + + def self.subsection(title) + puts SUB_SEPARATOR + puts title + puts SUB_SEPARATOR + end + + def self.format_time(seconds) + if seconds < 1e-6 + format("%.2f ns", seconds * 1e9) + elsif seconds < 1e-3 + format("%.2f \u00B5s", seconds * 1e6) + elsif seconds < 1.0 + format("%.2f ms", seconds * 1e3) + else + format("%.2f s", seconds) + end + end + + def self.format_ratio(candidate_time, baseline_time) + return nil unless candidate_time && baseline_time + + ratio = candidate_time / baseline_time + format("x%.2f", ratio) + end + + # Returns minimum time per iteration (in seconds) + def self.measure(warmup: 3, rounds: 10, &block) + iterations = calibrate(&block) + + warmup.times { iterations.times { block.call } } + + times = Array.new(rounds) do + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + iterations.times { block.call } + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + elapsed / iterations + end + times.min + end + + def self.calibrate(&block) # rubocop:disable Metrics/MethodLength + target = 0.5 + iters = 1 + loop do + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + iters.times { block.call } + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + return [iters, 1].max if elapsed >= target + return [iters, 1].max if iters > 1_000_000 + + factor = elapsed > 0.001 ? (target / elapsed).ceil : 10 + iters = [iters * factor, iters * 10].min + end + end +end + +def load_json(filename) + JSON.parse(File.read(File.join(BENCHMARK_DATA_PATH, filename))) +end + +def load_json_str(filename) + File.read(File.join(BENCHMARK_DATA_PATH, filename)) +end + +BENCHMARKS = [ + { name: "OpenAPI", schema: "openapi.json", instance: "zuora.json" }, + { name: "Swagger", schema: "swagger.json", instance: "kubernetes.json" }, + { name: "GeoJSON", schema: "geojson.json", instance: "canada.json" }, + { name: "CITM Catalog", schema: "citm_catalog_schema.json", instance: "citm_catalog.json" }, + { name: "Fast (Valid)", schema: "fast_schema.json", instance: "fast_valid.json" }, + { name: "Fast (Invalid)", schema: "fast_schema.json", instance: "fast_invalid.json" }, + { name: "FHIR", schema: "fhir.schema.json", instance: "patient-example-d.json" }, + { name: "Recursive", schema: "recursive_schema.json", instance: "recursive_instance.json" } +].freeze + +# Pre-load all unique JSON files (parsed and raw strings) +DATA = {} # rubocop:disable Style/MutableConstant +DATA_STR = {} # rubocop:disable Style/MutableConstant +BENCHMARKS.each do |b| + DATA_STR[b[:schema]] ||= load_json_str(b[:schema]) + DATA_STR[b[:instance]] ||= load_json_str(b[:instance]) + DATA[b[:schema]] ||= JSON.parse(DATA_STR[b[:schema]]) + DATA[b[:instance]] ||= JSON.parse(DATA_STR[b[:instance]]) +end +DATA.freeze +DATA_STR.freeze + +def try_compile(lib, schema, schema_str, instance_str) # rubocop:disable Metrics/MethodLength + case lib + when "jsonschema" + JSONSchema.validator_for(schema) + when "json_schemer" + JSONSchemer.schema(schema) + when "json-schema" + # fully_validate raises JSON::Schema::SchemaError for unsupported drafts + # (e.g. Draft 7), while validate() silently returns false. + JSON::Validator.fully_validate(schema, {}) + :class_method + when "rj_schema" + # rj_schema uses RapidJSON (C++); verify it produces correct results + # by cross-checking against jsonschema (Rust) + result = RjSchema::Validator.new.validate(schema_str, instance_str) + rj_valid = result[:machine_errors].empty? + rs_valid = JSONSchema.valid?(schema, JSON.parse(instance_str)) + raise "rj_schema disagrees with jsonschema" if rj_valid != rs_valid + + :rj_validator + end +rescue StandardError => e + warn " #{lib}: skipped (#{e.class}: #{e.message[0..80]})" + nil +end + +def make_validate_proc(lib, validator, schema, instance, schema_str, instance_str) + case lib + when "jsonschema", "json_schemer" + -> { validator.valid?(instance) } + when "json-schema" + -> { JSON::Validator.validate(schema, instance) } + when "rj_schema" + # rj_schema works with JSON strings; includes parse time + -> { RjSchema::Validator.new.validate(schema_str, instance_str) } + end +end + +def print_lib_result(lib, value) + puts " #{lib.ljust(15)} #{value}" +end + +def print_table_row(name, col1, col2, col3, col4) + puts "| #{name.to_s.ljust(20)} | #{col1.to_s.ljust(30)} | #{col2.to_s.ljust(30)} " \ + "| #{col3.to_s.ljust(30)} | #{col4.to_s.ljust(25)} |" +end + +LIBRARIES = %w[json-schema rj_schema json_schemer jsonschema].freeze + +BenchHelper.section("JSON Schema Validation Benchmarks") +puts "jsonschema (Rust) vs json_schemer vs json-schema vs rj_schema" +rust_version = `rustc --version 2>/dev/null`.strip.split[1] || "unknown" +puts "Ruby #{RUBY_VERSION}, Rust #{rust_version}" +puts + +results = [] + +BENCHMARKS.each do |bench| + BenchHelper.subsection(bench[:name]) + + schema = DATA[bench[:schema]] + instance = DATA[bench[:instance]] + schema_str = DATA_STR[bench[:schema]] + instance_str = DATA_STR[bench[:instance]] + + validators = {} + LIBRARIES.each { |lib| validators[lib] = try_compile(lib, schema, schema_str, instance_str) } + + row = { name: bench[:name] } + + LIBRARIES.each do |lib| + v = validators[lib] + unless v + row[lib] = nil + print_lib_result(lib, "-") + next + end + + validate_proc = make_validate_proc(lib, v, schema, instance, schema_str, instance_str) + time = BenchHelper.measure(&validate_proc) + row[lib] = time + print_lib_result(lib, BenchHelper.format_time(time)) + rescue StandardError => e + row[lib] = nil + print_lib_result(lib, "error: #{e.message[0..60]}") + end + + results << row + puts +end + +# Print markdown summary table +BenchHelper.section("Summary") + +baseline_lib = "jsonschema" +libs = %w[json-schema rj_schema json_schemer] +header = "| #{'Benchmark'.ljust(20)} | #{'json-schema'.ljust(30)} | #{'rj_schema'.ljust(30)} " \ + "| #{'json_schemer'.ljust(30)} | #{'jsonschema (validate)'.ljust(25)} |" +separator = "|#{'-' * 22}|#{'-' * 32}|#{'-' * 32}|#{'-' * 32}|#{'-' * 27}|" + +puts header +puts separator + +results.each do |row| + baseline = row[baseline_lib] + cols = libs.map do |lib| + t = row[lib] + if t.nil? + "-" + elsif baseline + ratio = BenchHelper.format_ratio(t, baseline) + "#{BenchHelper.format_time(t)} (**#{ratio}**)" + else + BenchHelper.format_time(t) + end + end + jsonschema_col = baseline ? BenchHelper.format_time(baseline) : "-" + print_table_row(row[:name], cols[0], cols[1], cols[2], jsonschema_col) +end + +puts +BenchHelper.section("Benchmarks complete!") diff --git a/crates/jsonschema-rb/ext/jsonschema/Cargo.lock b/crates/jsonschema-rb/ext/jsonschema/Cargo.lock new file mode 100644 index 000000000..865e4cf3a --- /dev/null +++ b/crates/jsonschema-rb/ext/jsonschema/Cargo.lock @@ -0,0 +1,2062 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9320368abda84a3aad44953d61e70515681fe43ae94529dff5567e0215b88438" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-bigint", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "reqwest", + "rustls", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-rb-ext" +version = "0.41.0" +dependencies = [ + "jsonschema", + "magnus", + "rb-sys", + "serde", + "serde_json", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "magnus" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b36a5b126bbe97eb0d02d07acfeb327036c6319fd816139a49824a83b7f9012" +dependencies = [ + "magnus-macros", + "rb-sys", + "rb-sys-env", + "seq-macro", +] + +[[package]] +name = "magnus-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47607461fd8e1513cb4f2076c197d8092d921a1ea75bd08af97398f593751892" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rb-sys" +version = "0.9.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85c4188462601e2aa1469def389c17228566f82ea72f137ed096f21591bc489" +dependencies = [ + "rb-sys-build", +] + +[[package]] +name = "rb-sys-build" +version = "0.9.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568068db4102230882e6d4ae8de6632e224ca75fe5970f6e026a04e91ed635d3" +dependencies = [ + "bindgen", + "lazy_static", + "proc-macro2", + "quote", + "regex", + "shell-words", + "syn", +] + +[[package]] +name = "rb-sys-env" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca7ad6a7e21e72151d56fe2495a259b5670e204c3adac41ee7ef676ea08117a" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5773259506800a8c6ef38df3674b061c62c5941162507891ff8b580e93d954e" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/crates/jsonschema-rb/ext/jsonschema/Cargo.toml b/crates/jsonschema-rb/ext/jsonschema/Cargo.toml new file mode 100644 index 000000000..4024dbd8e --- /dev/null +++ b/crates/jsonschema-rb/ext/jsonschema/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "jsonschema-rb-ext" +version = "0.41.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] +name = "jsonschema_rb" +path = "../../src/lib.rs" + +[dependencies] +jsonschema = { version = "0.41.0", default-features = false, features = [ + "arbitrary-precision", + "resolve-file", + "resolve-http", +] } +magnus = { version = "0.8", features = ["rb-sys"] } +rb-sys = "0.9" +referencing = "0.41.0" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["arbitrary_precision"] } + +[workspace] diff --git a/crates/jsonschema-rb/ext/jsonschema/extconf.rb b/crates/jsonschema-rb/ext/jsonschema/extconf.rb new file mode 100644 index 000000000..9711d429e --- /dev/null +++ b/crates/jsonschema-rb/ext/jsonschema/extconf.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "mkmf" +require "rb_sys/mkmf" + +create_rust_makefile("jsonschema/jsonschema_rb") do |r| + r.auto_install_rust_toolchain = false + + musl_target = + ENV["CARGO_BUILD_TARGET"]&.include?("musl") || + File.exist?("/etc/alpine-release") || + begin + `ldd --version 2>&1` =~ /musl/ + rescue StandardError + false + end || + `rustc -vV 2>/dev/null`[/host: (.+)/, 1]&.include?("musl") + + if musl_target + # Disable static CRT on musl. + r.extra_rustflags = ["-C", "target-feature=-crt-static"] + end +end diff --git a/crates/jsonschema-rb/extconf.rb b/crates/jsonschema-rb/extconf.rb new file mode 100644 index 000000000..c28bae063 --- /dev/null +++ b/crates/jsonschema-rb/extconf.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "mkmf" +require "rb_sys/mkmf" + +# Build the jsonschema-rb crate and output as jsonschema_rb.so +# This matches the crate name (jsonschema-rb -> jsonschema_rb) +create_rust_makefile("jsonschema/jsonschema_rb") do |r| + r.auto_install_rust_toolchain = false + + musl_target = + ENV["CARGO_BUILD_TARGET"]&.include?("musl") || + File.exist?("/etc/alpine-release") || + begin + `ldd --version 2>&1` =~ /musl/ + rescue StandardError + false + end || + `rustc -vV 2>/dev/null`[/host: (.+)/, 1]&.include?("musl") + + if musl_target + # Disable static CRT on musl. + r.extra_rustflags = ["-C", "target-feature=-crt-static"] + end +end diff --git a/crates/jsonschema-rb/jsonschema.gemspec b/crates/jsonschema-rb/jsonschema.gemspec new file mode 100644 index 000000000..295ac91db --- /dev/null +++ b/crates/jsonschema-rb/jsonschema.gemspec @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "lib/jsonschema/version" + +Gem::Specification.new do |spec| + spec.name = "jsonschema" + spec.version = JSONSchema::VERSION + spec.authors = ["Dmitry Dygalo"] + spec.email = ["dmitry@dygalo.dev"] + + spec.summary = "A high-performance JSON Schema validator for Ruby" + spec.description = "High-performance JSON Schema validator with support for Draft 4, 6, 7, 2019-09, and 2020-12." + spec.homepage = "https://github.com/Stranger6667/jsonschema/tree/master/crates/jsonschema-rb" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.2.0" + spec.required_rubygems_version = ">= 3.3.11" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/Stranger6667/jsonschema" + spec.metadata["changelog_uri"] = "https://github.com/Stranger6667/jsonschema/blob/master/crates/jsonschema-rb/CHANGELOG.md" + spec.metadata["documentation_uri"] = "https://github.com/Stranger6667/jsonschema/tree/master/crates/jsonschema-rb#readme" + spec.metadata["bug_tracker_uri"] = "https://github.com/Stranger6667/jsonschema/issues" + spec.metadata["funding_uri"] = "https://github.com/sponsors/Stranger6667" + spec.metadata["rubygems_mfa_required"] = "true" + + spec.files = Dir[ + "lib/**/*.rb", + "src/**/*.rs", + "ext/**/*.{rs,rb,toml,lock}", + "sig/**/*.rbs", + "Cargo.toml", + "Cargo.lock", + "LICENSE", + "README.md", + "CHANGELOG.md", + "MIGRATION.md" + ] + spec.require_paths = ["lib"] + spec.add_dependency "rb_sys", "~> 0.9.124" + # Build via the standalone extension config in ext/jsonschema. + spec.extensions = ["ext/jsonschema/extconf.rb"] +end diff --git a/crates/jsonschema-rb/justfile b/crates/jsonschema-rb/justfile new file mode 100644 index 000000000..4daac4692 --- /dev/null +++ b/crates/jsonschema-rb/justfile @@ -0,0 +1,23 @@ +# Build the native extension +build: + bundle exec rake compile + +# Run tests +test: build + bundle exec rspec + +# Lint Ruby code +lint: + bundle exec rubocop + +# Clean build artifacts +clean: + bundle exec rake clean + +# Install dependencies +setup: + bundle install + +# Run benchmarks +bench: build + bundle exec ruby bench/benchmark.rb diff --git a/crates/jsonschema-rb/lib/jsonschema.rb b/crates/jsonschema-rb/lib/jsonschema.rb new file mode 100644 index 000000000..e19e8ec39 --- /dev/null +++ b/crates/jsonschema-rb/lib/jsonschema.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "jsonschema/version" + +begin + RUBY_VERSION =~ /(\d+\.\d+)/ + require "jsonschema/#{Regexp.last_match(1)}/jsonschema_rb" +rescue LoadError + require "jsonschema/jsonschema_rb" +end + +# High-performance JSON Schema validator for Ruby. +# +# @example Quick validation with Hash +# JSONSchema.valid?({ "type" => "string" }, "hello") #=> true +# +# @example Quick validation with JSON string +# JSONSchema.valid?('{"type":"string"}', "hello") #=> true +# +# @example Reusable validator +# validator = JSONSchema.validator_for(schema) +# validator.valid?(data) +# +# @example Error iteration +# validator.each_error(data) do |error| +# puts error.message +# end +# +# @example Structured evaluation output +# eval = validator.evaluate(data) +# puts eval.flag # Simple valid/invalid +# puts eval.list # Flat list format +# +# @see https://json-schema.org/ JSON Schema specification +module JSONSchema +end diff --git a/crates/jsonschema-rb/lib/jsonschema/version.rb b/crates/jsonschema-rb/lib/jsonschema/version.rb new file mode 100644 index 000000000..4ddd61c08 --- /dev/null +++ b/crates/jsonschema-rb/lib/jsonschema/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module JSONSchema + VERSION = "0.41.0" +end diff --git a/crates/jsonschema-rb/sig/jsonschema.rbs b/crates/jsonschema-rb/sig/jsonschema.rbs new file mode 100644 index 000000000..3e65c3eeb --- /dev/null +++ b/crates/jsonschema-rb/sig/jsonschema.rbs @@ -0,0 +1,385 @@ +# Type definitions for jsonschema gem +# High-performance JSON Schema validation for Ruby + +module JSONSchema + VERSION: String + + # Valid draft version symbols + type draft = :draft4 | :draft6 | :draft7 | :draft201909 | :draft202012 + + # Create a validator with auto-detected draft version. + # + # @param schema The JSON Schema (Hash or JSON string) + # @param validate_formats Enable format validation + # @param ignore_unknown_formats Ignore unknown formats + # @param base_uri Base URI for reference resolution + # @param retriever Custom schema retriever proc + # @param mask Mask sensitive data in error messages + # @param formats Custom format validators (Hash of format_name => proc) + # @param keywords Custom keyword validators (Hash of keyword_name => class) + def self.validator_for: ( + untyped schema, + ?validate_formats: bool?, + ?ignore_unknown_formats: bool?, + ?base_uri: String?, + ?retriever: (^(String) -> untyped)?, + ?mask: String?, + ?formats: Hash[String, ^(String) -> bool]?, + ?keywords: Hash[String, Class]?, + ?registry: Registry?, + ?pattern_options: (RegexOptions | FancyRegexOptions)?, + ?email_options: EmailOptions?, + ?http_options: HttpOptions? + ) -> Validator + + # One-off validation returning boolean. + def self.valid?: ( + untyped schema, + untyped instance, + ?draft: draft?, + ?validate_formats: bool?, + ?ignore_unknown_formats: bool?, + ?base_uri: String?, + ?retriever: (^(String) -> untyped)?, + ?mask: String?, + ?formats: Hash[String, ^(String) -> bool]?, + ?keywords: Hash[String, Class]?, + ?registry: Registry?, + ?pattern_options: (RegexOptions | FancyRegexOptions)?, + ?email_options: EmailOptions?, + ?http_options: HttpOptions? + ) -> bool + + # One-off validation raising on error. + def self.validate!: ( + untyped schema, + untyped instance, + ?draft: draft?, + ?validate_formats: bool?, + ?ignore_unknown_formats: bool?, + ?base_uri: String?, + ?retriever: (^(String) -> untyped)?, + ?mask: String?, + ?formats: Hash[String, ^(String) -> bool]?, + ?keywords: Hash[String, Class]?, + ?registry: Registry?, + ?pattern_options: (RegexOptions | FancyRegexOptions)?, + ?email_options: EmailOptions?, + ?http_options: HttpOptions? + ) -> nil + + # Iterate validation errors. Returns Array when no block given, nil when block given. + def self.each_error: ( + untyped schema, + untyped instance, + ?draft: draft?, + ?validate_formats: bool?, + ?ignore_unknown_formats: bool?, + ?base_uri: String?, + ?retriever: (^(String) -> untyped)?, + ?mask: String?, + ?formats: Hash[String, ^(String) -> bool]?, + ?keywords: Hash[String, Class]?, + ?registry: Registry?, + ?pattern_options: (RegexOptions | FancyRegexOptions)?, + ?email_options: EmailOptions?, + ?http_options: HttpOptions? + ) -> Array[ValidationError] + | ( + untyped schema, + untyped instance, + ?draft: draft?, + ?validate_formats: bool?, + ?ignore_unknown_formats: bool?, + ?base_uri: String?, + ?retriever: (^(String) -> untyped)?, + ?mask: String?, + ?formats: Hash[String, ^(String) -> bool]?, + ?keywords: Hash[String, Class]?, + ?registry: Registry?, + ?pattern_options: (RegexOptions | FancyRegexOptions)?, + ?email_options: EmailOptions?, + ?http_options: HttpOptions? + ) { (ValidationError) -> void } -> nil + + # One-off evaluation returning structured output. + def self.evaluate: ( + untyped schema, + untyped instance, + ?draft: draft?, + ?validate_formats: bool?, + ?ignore_unknown_formats: bool?, + ?base_uri: String?, + ?retriever: (^(String) -> untyped)?, + ?formats: Hash[String, ^(String) -> bool]?, + ?keywords: Hash[String, Class]?, + ?registry: Registry?, + ?pattern_options: (RegexOptions | FancyRegexOptions)?, + ?email_options: EmailOptions?, + ?http_options: HttpOptions? + ) -> Evaluation + + # Reusable JSON Schema validator. + private + class Validator + # Fast validation returning boolean. + def valid?: (untyped instance) -> bool + + # Validate and raise on first error. + def validate!: (untyped instance) -> nil + + # Iterate validation errors. Returns Array when no block given, nil when block given. + def each_error: (untyped instance) -> Array[ValidationError] + | (untyped instance) { (ValidationError) -> void } -> nil + + # Evaluate instance and return structured output. + def evaluate: (untyped instance) -> Evaluation + + def inspect: () -> String + end + public + + # JSON Schema Draft 4 validator. + class Draft4Validator < Validator + end + + # JSON Schema Draft 6 validator. + class Draft6Validator < Validator + end + + # JSON Schema Draft 7 validator. + class Draft7Validator < Validator + end + + # JSON Schema Draft 2019-09 validator. + class Draft201909Validator < Validator + end + + # JSON Schema Draft 2020-12 validator. + class Draft202012Validator < Validator + end + + # Validation error with detailed information. + class ValidationError < StandardError + # Brief error message. + attr_reader message: String + + # Detailed error message with full context. + attr_reader verbose_message: String + + # Path to the failing instance location. + attr_reader instance_path: Array[String | Integer] + + # Path to the failing schema location. + attr_reader schema_path: Array[String | Integer] + + # Evaluation path. + attr_reader evaluation_path: Array[String | Integer] + + # The specific kind of validation error. + attr_reader kind: ValidationErrorKind + + # The failing instance value. + attr_reader instance: untyped + + def initialize: ( + message: String, + ?verbose_message: String?, + ?instance_path: Array[String | Integer], + ?schema_path: Array[String | Integer], + ?evaluation_path: Array[String | Integer], + ?kind: ValidationErrorKind?, + ?instance: untyped + ) -> void + + def to_s: () -> String + def inspect: () -> String + + # Compare two validation errors by message, schema_path, and instance_path. + def ==: (untyped other) -> bool + alias eql? == + + # Hash code based on message, schema_path, and instance_path. + def hash: () -> Integer + + # Convert instance_path to JSON Pointer format (RFC 6901). + def instance_path_pointer: () -> String + + # Convert schema_path to JSON Pointer format (RFC 6901). + def schema_path_pointer: () -> String + + # Evaluation path as JSON Pointer string (RFC 6901), pre-computed in Rust. + def evaluation_path_pointer: () -> String + end + + # Type of validation error with contextual data. + class ValidationErrorKind + # Keyword name that caused the error (e.g., "type", "required", "format"). + def name: () -> String + + # Associated value for the error (varies by error type). + def value: () -> untyped + + # Convert to hash representation. + def to_h: () -> Hash[Symbol, untyped] + + def inspect: () -> String + def to_s: () -> String + end + + # Structured evaluation output following JSON Schema specification. + class Evaluation + # Whether the instance is valid. + def valid?: () -> bool + + # Flag output format (simplest). + # Returns { valid: true/false } + def flag: () -> Hash[Symbol, untyped] + + # List output format (flat). + # Contains { valid: bool, details: [...] } + def list: () -> Hash[Symbol, untyped] + + # Hierarchical output format (nested). + # Contains nested structure following schema hierarchy + def hierarchical: () -> Hash[Symbol, untyped] + + # Collected annotations from all evaluated nodes. + # Each entry contains schemaLocation, instanceLocation, and annotations + def annotations: () -> Array[Hash[Symbol, untyped]] + + # Collected errors from all evaluated nodes. + # Each entry contains schemaLocation, instanceLocation, error, and absoluteKeywordLocation + def errors: () -> Array[Hash[Symbol, untyped]] + + def inspect: () -> String + end + + # Schema registry for reference resolution. + class Registry + # Create a registry from URI/schema pairs. + # @param resources Array of [uri, schema] pairs + # @param draft Optional draft version constant + # @param retriever Custom schema retriever proc for dynamic resolution + def initialize: (Array[[String, untyped]] resources, ?draft: draft?, ?retriever: (^(String) -> untyped)?) -> void + + def inspect: () -> String + end + + # Raised when a reference cannot be resolved. + class ReferencingError < StandardError + attr_reader message: String + def initialize: (String message) -> void + end + + # Meta-schema validation. + module Meta + # Validate a schema against its meta-schema. + def self.valid?: (untyped schema, ?registry: Registry?) -> bool + + # Validate a schema and raise on error. + def self.validate!: (untyped schema, ?registry: Registry?) -> nil + end + + # Email format validation options. + class EmailOptions + # Create email validation options. + # @param require_tld Require top-level domain (default: false) + # @param allow_domain_literal Allow domain literals like [127.0.0.1] (default: true) + # @param allow_display_text Allow display names like "Name " (default: true) + # @param minimum_sub_domains Minimum number of subdomains required (default: nil) + def initialize: ( + ?require_tld: bool?, + ?allow_domain_literal: bool?, + ?allow_display_text: bool?, + ?minimum_sub_domains: Integer? + ) -> void + + # Whether TLD is required. + def require_tld: () -> bool + + # Whether domain literals are allowed. + def allow_domain_literal: () -> bool + + # Whether display text is allowed. + def allow_display_text: () -> bool + + # Minimum number of subdomains. + def minimum_sub_domains: () -> Integer? + + def inspect: () -> String + def to_s: () -> String + end + + # Regex engine options. + class RegexOptions + # Create regex engine options. + # @param size_limit Maximum compiled regex size in bytes + # @param dfa_size_limit Maximum DFA size in bytes + def initialize: (?size_limit: Integer?, ?dfa_size_limit: Integer?) -> void + + # Maximum compiled regex size. + def size_limit: () -> Integer? + + # Maximum DFA size. + def dfa_size_limit: () -> Integer? + + def inspect: () -> String + def to_s: () -> String + end + + # Fancy regex engine options (ECMA-262 patterns). + class FancyRegexOptions + # Create fancy regex engine options. + # @param backtrack_limit Maximum backtracking steps + # @param size_limit Maximum compiled regex size + # @param dfa_size_limit Maximum DFA size + def initialize: ( + ?backtrack_limit: Integer?, + ?size_limit: Integer?, + ?dfa_size_limit: Integer? + ) -> void + + # Maximum backtracking steps. + def backtrack_limit: () -> Integer? + + # Maximum compiled regex size. + def size_limit: () -> Integer? + + # Maximum DFA size. + def dfa_size_limit: () -> Integer? + + def inspect: () -> String + def to_s: () -> String + end + + # HTTP client options for remote schema retrieval. + class HttpOptions + # Create HTTP client options. + # @param timeout Request timeout in seconds + # @param connect_timeout Connection timeout in seconds + # @param tls_verify Verify TLS certificates (default: true) + # @param ca_cert Path to CA certificate file + def initialize: ( + ?timeout: Float?, + ?connect_timeout: Float?, + ?tls_verify: bool?, + ?ca_cert: String? + ) -> void + + # Request timeout in seconds. + def timeout: () -> Float? + + # Connection timeout in seconds. + def connect_timeout: () -> Float? + + # Whether to verify TLS certificates. + def tls_verify: () -> bool + + # Path to CA certificate file. + def ca_cert: () -> String? + + def inspect: () -> String + def to_s: () -> String + end +end diff --git a/crates/jsonschema-rb/spec/jsonschema_spec.rb b/crates/jsonschema-rb/spec/jsonschema_spec.rb new file mode 100644 index 000000000..98154d7a8 --- /dev/null +++ b/crates/jsonschema-rb/spec/jsonschema_spec.rb @@ -0,0 +1,1839 @@ +# frozen_string_literal: true + +require "spec_helper" +require "weakref" + +RSpec.describe JSONSchema do + describe ".valid?" do + it "returns true for valid instance" do + schema = { "type" => "string" } + expect(JSONSchema.valid?(schema, "hello")).to be true + end + + it "returns false for invalid instance" do + schema = { "type" => "string" } + expect(JSONSchema.valid?(schema, 42)).to be false + end + end + + describe ".validate!" do + it "returns nil for valid instance" do + schema = { "type" => "string" } + expect(JSONSchema.validate!(schema, "hello")).to be_nil + end + + it "raises ValidationError for invalid instance" do + schema = { "type" => "string" } + expect { JSONSchema.validate!(schema, 42) }.to raise_error(JSONSchema::ValidationError) + end + + it "raises ReferencingError for unresolved $ref" do + schema = { "$ref" => "#/$defs/missing" } + expect { JSONSchema.validate!(schema, "hello") }.to raise_error(JSONSchema::ReferencingError) + end + end + + describe ".each_error" do + let(:schema) { { "type" => "string" } } + + it "returns empty array for valid instance" do + errors = JSONSchema.each_error(schema, "hello") + expect(errors).to eq([]) + end + + it "returns array of errors for invalid instance" do + errors = JSONSchema.each_error(schema, 42) + expect(errors.size).to eq(1) + expect(errors.first).to be_a(JSONSchema::ValidationError) + end + + it "yields errors when block given" do + collected = [] + JSONSchema.each_error(schema, 42) { |e| collected << e } + expect(collected.size).to eq(1) + expect(collected.first).to be_a(JSONSchema::ValidationError) + end + + it "returns nil when block given" do + seen = false + result = JSONSchema.each_error(schema, 42) { |_error| seen = true } + expect(result).to be_nil + expect(seen).to be true + end + + it "supports early termination with break" do + # Schema that produces multiple errors + schema = { "type" => "object", "required" => %w[a b c] } + collected = [] + JSONSchema.each_error(schema, {}) do |e| + collected << e + break if collected.size == 1 + end + expect(collected.size).to eq(1) + expect(collected.first).to be_a(JSONSchema::ValidationError) + end + end + + describe ".evaluate" do + it "returns valid evaluation for valid instance" do + schema = { "type" => "string" } + eval_result = JSONSchema.evaluate(schema, "hello") + expect(eval_result.valid?).to be true + expect(eval_result.errors).to eq([]) + end + + it "returns invalid evaluation for invalid instance" do + schema = { "type" => "string" } + eval_result = JSONSchema.evaluate(schema, 42) + expect(eval_result.valid?).to be false + expect(eval_result.errors.size).to eq(1) + end + + it "rejects mask option" do + schema = { "type" => "string" } + expect { JSONSchema.evaluate(schema, 42, mask: "[HIDDEN]") } + .to raise_error(ArgumentError, /unknown keyword: :mask/) + end + end + + describe ".validator_for" do + it "creates a validator" do + schema = { "type" => "string" } + validator = JSONSchema.validator_for(schema) + expect(validator).to respond_to(:valid?) + expect(validator).to respond_to(:validate!) + expect(validator).to respond_to(:each_error) + expect(validator).to respond_to(:evaluate) + end + + it "rejects draft keyword argument" do + schema = { "type" => "string" } + expect { JSONSchema.validator_for(schema, draft: :draft7) } + .to raise_error(ArgumentError, /unknown keyword: :draft/) + end + + it "raises ReferencingError for unresolved $ref" do + schema = { "$ref" => "#/$defs/missing" } + expect { JSONSchema.validator_for(schema) }.to raise_error(JSONSchema::ReferencingError) + end + end +end + +RSpec.describe "Reusable validators" do + describe "#inspect" do + { + JSONSchema::Draft4Validator => "Draft4", + JSONSchema::Draft6Validator => "Draft6", + JSONSchema::Draft7Validator => "Draft7", + JSONSchema::Draft201909Validator => "Draft201909", + JSONSchema::Draft202012Validator => "Draft202012" + }.each do |klass, name| + it "shows #{name} for #{klass}" do + v = klass.new({ "type" => "string" }) + expect(v.inspect).to eq("#") + end + end + end + + describe "with keyword arguments via validator_for" do + it "creates validator with options" do + schema = { "type" => "string", "format" => "email" } + validator = JSONSchema.validator_for(schema, validate_formats: true) + expect(validator.valid?("test@example.com")).to be true + expect(validator.valid?("invalid")).to be false + end + + it "accepts mask option" do + schema = { "type" => "string" } + validator = JSONSchema.validator_for(schema, mask: "[REDACTED]") + expect { validator.validate!(42) }.to raise_error(JSONSchema::ValidationError) do |error| + expect(error.message).to eq('[REDACTED] is not of type "string"') + end + end + + it "accepts custom formats" do + schema = { "type" => "string", "format" => "my-format" } + my_format = ->(value) { value.end_with?("42!") } + validator = JSONSchema.validator_for(schema, validate_formats: true, formats: { "my-format" => my_format }) + expect(validator.valid?("foo42!")).to be true + expect(validator.valid?("foo")).to be false + end + end +end + +RSpec.describe "Custom formats" do + describe "with module-level functions" do + it "validates with custom format" do + schema = { "type" => "string", "format" => "my-format" } + my_format = ->(value) { value.end_with?("42!") } + expect(JSONSchema.valid?(schema, "bar42!", validate_formats: true, formats: { "my-format" => my_format })).to be true + expect(JSONSchema.valid?(schema, "bar", validate_formats: true, formats: { "my-format" => my_format })).to be false + end + end + + describe "with draft-specific validators" do + it "accepts custom formats" do + schema = { "type" => "string", "format" => "custom" } + custom_format = ->(value) { value.start_with?("valid:") } + validator = JSONSchema::Draft7Validator.new(schema, validate_formats: true, formats: { "custom" => custom_format }) + expect(validator.valid?("valid:data")).to be true + expect(validator.valid?("invalid")).to be false + end + end + + describe "callback lifetime" do + it "keeps custom format procs alive for validator lifetime" do + schema = { "type" => "string", "format" => "my-format" } + checker = ->(value) { value.end_with?("42!") } + weak_checker = WeakRef.new(checker) + + validator = JSONSchema.validator_for( + schema, + validate_formats: true, + formats: { "my-format" => checker } + ) + GC.start(full_mark: true, immediate_sweep: true) + + expect(weak_checker.weakref_alive?).to be true + expect(validator.valid?("foo42!")).to be true + expect(validator.valid?("foo")).to be false + end + + it "keeps custom format procs alive during schema compilation" do + 100.times do |iteration| + observed_alive = nil + checker = ->(value) { value.end_with?("42!") } + weak_checker = WeakRef.new(checker) + formats = { "my-format" => checker } + + retriever = lambda do |_uri| + formats.clear + GC.start(full_mark: true, immediate_sweep: true) + GC.compact if GC.respond_to?(:compact) + observed_alive = weak_checker.weakref_alive? + { "type" => "string" } + end + + validator = JSONSchema.validator_for( + { + "allOf" => [ + { "$ref" => "https://example.com/string.json" }, + { "type" => "string", "format" => "my-format" } + ] + }, + validate_formats: true, + formats: formats, + retriever: retriever + ) + + expect(validator.valid?("foo42!")).to be true + expect(validator.valid?("foo")).to be false + expect(observed_alive).to be(true), "format proc was collected on iteration #{iteration}" + end + end + end +end + +RSpec.describe JSONSchema::ValidationError do + it "has message and paths" do + schema = { "type" => "string" } + begin + JSONSchema.validate!(schema, 42) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.message).to eq('42 is not of type "string"') + expect(e.instance_path).to eq([]) + expect(e.schema_path).to eq(["type"]) + end + end + + describe "#==" do + it "returns true for errors with same message, schema_path, and instance_path" do + schema = { "type" => "string" } + e1 = JSONSchema.each_error(schema, 42).first + e2 = JSONSchema.each_error(schema, 42).first + expect(e1).to eq(e2) + end + + it "returns false for errors with different schema paths" do + e1 = JSONSchema.each_error({ "type" => "string" }, 42).first + e2 = JSONSchema.each_error({ "type" => "integer" }, "hello").first + expect(e1).not_to eq(e2) + end + + it "returns false when compared to non-ValidationError" do + error = JSONSchema.each_error({ "type" => "string" }, 42).first + expect(error).not_to eq("not an error") + end + end + + describe "#hash" do + it "returns same hash for equal errors" do + schema = { "type" => "string" } + e1 = JSONSchema.each_error(schema, 42).first + e2 = JSONSchema.each_error(schema, 42).first + expect(e1.hash).to eq(e2.hash) + end + + it "works with Array#uniq" do + schema = { "type" => "string" } + e1 = JSONSchema.each_error(schema, 42).first + e2 = JSONSchema.each_error(schema, 42).first + expect([e1, e2].uniq.size).to eq(1) + end + + it "works as Hash keys" do + schema = { "type" => "string" } + e1 = JSONSchema.each_error(schema, 42).first + e2 = JSONSchema.each_error(schema, 42).first + h = { e1 => "first" } + expect(h[e2]).to eq("first") + end + end + + describe "#instance_path_pointer" do + it "converts instance path to JSON Pointer" do + schema = { + "type" => "object", + "properties" => { + "users" => { + "type" => "array", + "items" => { "type" => "string" } + } + } + } + begin + JSONSchema.validate!(schema, { "users" => [123] }) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.instance_path).to eq(["users", 0]) + expect(e.instance_path_pointer).to eq("/users/0") + end + end + end + + describe "#schema_path_pointer" do + it "converts schema path to JSON Pointer" do + schema = { "type" => "string" } + begin + JSONSchema.validate!(schema, 42) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.schema_path_pointer).to eq("/type") + end + end + end +end + +RSpec.describe JSONSchema::Evaluation do + let(:schema) { { "type" => "string" } } + + describe "#valid?" do + it "returns true for valid instance" do + eval = JSONSchema.evaluate(schema, "hello") + expect(eval.valid?).to be true + end + + it "returns false for invalid instance" do + eval = JSONSchema.evaluate(schema, 42) + expect(eval.valid?).to be false + end + end + + describe "#flag" do + it "returns flag output format" do + eval = JSONSchema.evaluate(schema, "hello") + flag = eval.flag + expect(flag).to be_a(Hash) + expect(flag[:valid]).to be true + end + + it "returns only valid key" do + eval = JSONSchema.evaluate(schema, "hello") + expect(eval.flag.keys).to eq([:valid]) + end + end + + describe "#list" do + it "returns list output format for valid instance" do + eval = JSONSchema.evaluate(schema, "hello") + list = eval.list + expect(list).to be_a(Hash) + expect(list[:valid]).to be true + end + + it "contains details for evaluation" do + eval = JSONSchema.evaluate(schema, 42) + list = eval.list + expect(list[:valid]).to be false + expect(list[:details]).to be_an(Array) + end + end + + describe "#hierarchical" do + it "returns hierarchical output format" do + eval = JSONSchema.evaluate(schema, "hello") + hier = eval.hierarchical + expect(hier).to be_a(Hash) + expect(hier[:valid]).to be true + end + + it "contains nested structure" do + schema = { "type" => "object", "properties" => { "name" => { "type" => "string" } } } + eval = JSONSchema.evaluate(schema, { "name" => "Alice" }) + hier = eval.hierarchical + expect(hier[:valid]).to be true + end + end + + describe "#annotations" do + it "returns array of annotations" do + schema = { "type" => "object", "title" => "Test" } + eval = JSONSchema.evaluate(schema, {}) + expect(eval.annotations).to be_an(Array) + end + end + + describe "#errors" do + it "returns empty array for valid instance" do + eval_result = JSONSchema.evaluate(schema, "hello") + expect(eval_result.errors).to eq([]) + end + + it "returns array of errors for invalid instance" do + eval_result = JSONSchema.evaluate(schema, 42) + expect(eval_result.errors.size).to eq(1) + error = eval_result.errors.first + expect(error).to have_key(:instanceLocation) + expect(error).to have_key(:schemaLocation) + expect(error).to have_key(:error) + end + end + + describe "#inspect" do + it "returns readable representation" do + eval_result = JSONSchema.evaluate(schema, "hello") + expect(eval_result.inspect).to eq("#") + end + end +end + +RSpec.describe JSONSchema::Meta do + describe ".valid?" do + it "returns true for valid schema" do + schema = { "type" => "string" } + expect(JSONSchema::Meta.valid?(schema)).to be true + end + + it "returns false for invalid schema" do + schema = { "type" => "invalid_type" } + expect(JSONSchema::Meta.valid?(schema)).to be false + end + + it "accepts optional registry keyword argument" do + registry = JSONSchema::Registry.new([ + ["https://example.com/ref.json", { "type" => "string" }] + ]) + schema = { "$ref" => "https://example.com/ref.json" } + expect(JSONSchema::Meta.valid?(schema, registry: registry)).to be true + end + end + + describe ".validate!" do + it "returns nil for valid schema" do + schema = { "type" => "string" } + expect(JSONSchema::Meta.validate!(schema)).to be_nil + end + + it "raises ValidationError for invalid schema" do + schema = { "type" => "invalid_type" } + expect { JSONSchema::Meta.validate!(schema) }.to raise_error(JSONSchema::ValidationError) + end + end +end + +RSpec.describe "Custom keywords" do + let(:even_validator_class) do + Class.new do + def initialize(_parent_schema, value, _schema_path) + @enabled = value + end + + def validate(instance) + return unless @enabled + return unless instance.is_a?(Integer) + + raise "#{instance} is not even" if instance.odd? + end + end + end + + let(:range_validator_class) do + Class.new do + def initialize(_parent_schema, value, _schema_path) + @min = value["min"] || -Float::INFINITY + @max = value["max"] || Float::INFINITY + end + + def validate(instance) + return unless instance.is_a?(Numeric) + + raise "Value #{instance} not in range [#{@min}, #{@max}]" unless (@min..@max).cover?(instance) + end + end + end + + describe "with validator_for" do + it "validates with custom even keyword" do + validator = JSONSchema.validator_for( + { "even" => true }, + keywords: { "even" => even_validator_class } + ) + expect(validator.valid?(2)).to be true + expect(validator.valid?(4)).to be true + expect(validator.valid?(1)).to be false + expect(validator.valid?(3)).to be false + expect(validator.valid?("not a number")).to be true + end + + it "can be disabled by setting value to false" do + validator = JSONSchema.validator_for( + { "even" => false }, + keywords: { "even" => even_validator_class } + ) + expect(validator.valid?(1)).to be true + expect(validator.valid?(3)).to be true + end + + it "works with standard keywords" do + validator = JSONSchema.validator_for( + { "type" => "integer", "minimum" => 0, "even" => true }, + keywords: { "even" => even_validator_class } + ) + expect(validator.valid?(2)).to be true + expect(validator.valid?(100)).to be true + expect(validator.valid?(3)).to be false + expect(validator.valid?(-2)).to be false + expect(validator.valid?("hello")).to be false + end + + it "supports nested schemas" do + validator = JSONSchema.validator_for( + { + "type" => "object", + "properties" => { + "count" => { "type" => "integer", "even" => true } + } + }, + keywords: { "even" => even_validator_class } + ) + expect(validator.valid?({ "count" => 2 })).to be true + expect(validator.valid?({ "count" => 3 })).to be false + end + + it "supports range validator with object value" do + validator = JSONSchema.validator_for( + { "customRange" => { "min" => 0, "max" => 10 } }, + keywords: { "customRange" => range_validator_class } + ) + expect(validator.valid?(5)).to be true + expect(validator.valid?(0)).to be true + expect(validator.valid?(10)).to be true + expect(validator.valid?(-1)).to be false + expect(validator.valid?(11)).to be false + end + end + + describe "with validate!" do + it "raises on validation error" do + validator = JSONSchema.validator_for( + { "even" => true }, + keywords: { "even" => even_validator_class } + ) + expect { validator.validate!(3) }.to raise_error(JSONSchema::ValidationError) do |error| + expect(error.message).to include("3 is not even") + end + end + end + + describe "with each_error" do + it "returns errors for invalid instance" do + validator = JSONSchema.validator_for( + { "even" => true }, + keywords: { "even" => even_validator_class } + ) + errors = validator.each_error(3).to_a + expect(errors.size).to eq(1) + expect(errors.first).to be_a(JSONSchema::ValidationError) + end + end + + describe "with module-level functions" do + it "works with valid?" do + expect(JSONSchema.valid?({ "even" => true }, 2, keywords: { "even" => even_validator_class })).to be true + expect(JSONSchema.valid?({ "even" => true }, 3, keywords: { "even" => even_validator_class })).to be false + end + + it "works with validate!" do + JSONSchema.validate!({ "even" => true }, 2, keywords: { "even" => even_validator_class }) + expect { JSONSchema.validate!({ "even" => true }, 3, keywords: { "even" => even_validator_class }) } + .to raise_error(JSONSchema::ValidationError) + end + + it "works with each_error" do + errors = JSONSchema.each_error({ "even" => true }, 3, keywords: { "even" => even_validator_class }).to_a + expect(errors.size).to eq(1) + end + end + + describe "with draft-specific validators" do + it "works with all drafts" do + [ + JSONSchema::Draft4Validator, + JSONSchema::Draft6Validator, + JSONSchema::Draft7Validator, + JSONSchema::Draft201909Validator, + JSONSchema::Draft202012Validator + ].each do |validator_class| + validator = validator_class.new({ "even" => true }, keywords: { "even" => even_validator_class }) + expect(validator.valid?(2)).to be true + expect(validator.valid?(3)).to be false + end + end + end + + describe "error handling" do + it "raises error for non-class keyword validator" do + expect { JSONSchema.validator_for({ "myKeyword" => true }, keywords: { "myKeyword" => "not a class" }) } + .to raise_error(TypeError, /must be a class with 'new' and 'validate' methods/) + end + end + + describe "lifetime management" do + it "does not leak keyword validator instances after validator disposal" do + keyword_class = Class.new do + def initialize(parent_schema, value, schema_path); end + + def validate(instance); end + end + + 100.times do + JSONSchema.validator_for( + { "customKeyword" => true }, + keywords: { "customKeyword" => keyword_class } + ) + end + + 10.times do + 10_000.times { +"x" } + GC.start(full_mark: true, immediate_sweep: true) + end + + expect(ObjectSpace.each_object(keyword_class).count).to be <= 2 + end + + it "keeps keyword validator instances alive while wrapping reusable validators" do + original_stress = GC.stress + constructors = { + "validator_for" => lambda do |schema, keywords| + JSONSchema.validator_for(schema, keywords: keywords) + end, + "Draft7Validator.new" => lambda do |schema, keywords| + JSONSchema::Draft7Validator.new(schema, keywords: keywords) + end + } + GC.stress = true + + constructors.each do |label, constructor| + 100.times do |iteration| + instance_ref = nil + + keyword_class = Class.new do + define_method(:initialize) do |_parent_schema, _value, _schema_path| + instance_ref = WeakRef.new(self) + end + + def validate(instance); end + end + + validator = constructor.call( + { "customKeyword" => true }, + { "customKeyword" => keyword_class } + ) + GC.start(full_mark: true, immediate_sweep: true) + GC.compact if GC.respond_to?(:compact) + + expect(instance_ref).not_to be_nil + expect(instance_ref.weakref_alive?).to be( + true + ), "#{label}: keyword instance was collected on iteration #{iteration}" + expect(validator.valid?(1)).to be true + end + end + ensure + GC.stress = original_stress + end + + it "keeps keyword validator classes alive until custom keywords are compiled" do + 100.times do |iteration| + observed_alive = nil + + keyword_class = Class.new do + def initialize(parent_schema, value, schema_path); end + + def validate(instance); end + end + keyword_class_ref = WeakRef.new(keyword_class) + + keywords = { "customKeyword" => keyword_class } + + retriever = lambda do |_uri| + keywords.clear + GC.start(full_mark: true, immediate_sweep: true) + GC.compact if GC.respond_to?(:compact) + observed_alive = keyword_class_ref.weakref_alive? + { "type" => "integer" } + end + + schema = { + "allOf" => [ + { "$ref" => "https://example.com/integer.json" }, + { "customKeyword" => true } + ] + } + + validator = JSONSchema.validator_for(schema, keywords: keywords, retriever: retriever) + expect(validator.valid?(1)).to be true + expect(observed_alive).to be(true), "keyword class was collected on iteration #{iteration}" + end + end + + it "keeps direct retriever callbacks alive during keyword compilation" do + 100.times do |iteration| + retriever_ref = nil + observed_alive = nil + + keyword_class = Class.new do + define_method(:initialize) do |_parent_schema, _value, _schema_path| + GC.start(full_mark: true, immediate_sweep: true) + GC.compact if GC.respond_to?(:compact) + observed_alive = retriever_ref.weakref_alive? + end + + def validate(instance); end + end + + schema = { + "allOf" => [ + { "customKeyword" => true }, + { "$ref" => "https://example.com/integer.json" } + ] + } + + validator = JSONSchema.validator_for( + schema, + keywords: { "customKeyword" => keyword_class }, + retriever: ->(_uri) { { "type" => "integer" } }.tap { |proc| retriever_ref = WeakRef.new(proc) } + ) + expect(validator.valid?(1)).to be true + expect(observed_alive).to be(true), "retriever proc was collected on iteration #{iteration}" + end + end + end +end + +RSpec.describe "Registry with validators" do + it "keeps retriever callback alive while registry exists" do + build_registry = lambda do + retriever = lambda do |uri| + { "type" => "string" } if uri == "https://example.com/string.json" + end + retriever_ref = WeakRef.new(retriever) + [JSONSchema::Registry.new([], retriever: retriever), retriever_ref] + end + + _registry, retriever_ref = build_registry.call + + 10.times do + break unless retriever_ref.weakref_alive? + + 10_000.times { +"x" } + GC.start(full_mark: true, immediate_sweep: true) + end + + expect(retriever_ref.weakref_alive?).to be true + end + + it "validates with registry on module-level valid?" do + registry = JSONSchema::Registry.new([ + ["https://example.com/string.json", { "type" => "string" }] + ]) + schema = { "$ref" => "https://example.com/string.json" } + expect(JSONSchema.valid?(schema, "hello", registry: registry)).to be true + expect(JSONSchema.valid?(schema, 42, registry: registry)).to be false + end + + it "validates with registry on module-level validate!" do + registry = JSONSchema::Registry.new([ + ["https://example.com/string.json", { "type" => "string" }] + ]) + schema = { "$ref" => "https://example.com/string.json" } + expect(JSONSchema.validate!(schema, "hello", registry: registry)).to be_nil + expect { JSONSchema.validate!(schema, 42, registry: registry) }.to raise_error(JSONSchema::ValidationError) + end + + it "validates with registry on module-level each_error" do + registry = JSONSchema::Registry.new([ + ["https://example.com/string.json", { "type" => "string" }] + ]) + schema = { "$ref" => "https://example.com/string.json" } + errors = JSONSchema.each_error(schema, 42, registry: registry).to_a + expect(errors).not_to be_empty + expect(errors.first).to be_a(JSONSchema::ValidationError) + end + + it "validates with registry on module-level evaluate" do + registry = JSONSchema::Registry.new([ + ["https://example.com/string.json", { "type" => "string" }] + ]) + schema = { "$ref" => "https://example.com/string.json" } + eval_result = JSONSchema.evaluate(schema, 42, registry: registry) + expect(eval_result.valid?).to be false + end + + it "validates with registry on validator_for" do + registry = JSONSchema::Registry.new([ + ["https://example.com/string.json", { "type" => "string" }] + ]) + schema = { "$ref" => "https://example.com/string.json" } + validator = JSONSchema.validator_for(schema, registry: registry) + expect(validator.valid?("hello")).to be true + expect(validator.valid?(42)).to be false + end + + it "uses registry retriever during validator compilation when only registry is provided" do + registry = JSONSchema::Registry.new( + [], + retriever: lambda do |uri| + { "type" => "string" } if uri == "https://example.com/string.json" + end + ) + validator = JSONSchema.validator_for({ "$ref" => "https://example.com/string.json" }, registry: registry) + expect(validator.valid?("hello")).to be true + expect(validator.valid?(42)).to be false + end + + it "validates with registry on draft-specific validators" do + registry = JSONSchema::Registry.new([ + ["https://example.com/string.json", { "type" => "string" }] + ]) + schema = { "$ref" => "https://example.com/string.json" } + [ + JSONSchema::Draft4Validator, + JSONSchema::Draft6Validator, + JSONSchema::Draft7Validator, + JSONSchema::Draft201909Validator, + JSONSchema::Draft202012Validator + ].each do |klass| + validator = klass.new(schema, registry: registry) + expect(validator.valid?("hello")).to be true + expect(validator.valid?(42)).to be false + end + end +end + +RSpec.describe "validate_formats option" do + it "does not validate formats by default" do + schema = { "type" => "string", "format" => "email" } + expect(JSONSchema.valid?(schema, "not-an-email")).to be true + end + + it "validates formats when enabled" do + schema = { "type" => "string", "format" => "email" } + expect(JSONSchema.valid?(schema, "not-an-email", validate_formats: true)).to be false + expect(JSONSchema.valid?(schema, "user@example.com", validate_formats: true)).to be true + end + + it "explicitly disables format validation" do + schema = { "type" => "string", "format" => "email" } + expect(JSONSchema.valid?(schema, "not-an-email", validate_formats: false)).to be true + end +end + +RSpec.describe "ignore_unknown_formats option" do + it "ignores unknown formats by default" do + schema = { "type" => "string", "format" => "totally-made-up" } + expect(JSONSchema.valid?(schema, "anything", validate_formats: true)).to be true + end + + it "raises on unknown formats when not ignored" do + schema = { "type" => "string", "format" => "totally-made-up" } + expect do + JSONSchema.valid?(schema, "anything", validate_formats: true, ignore_unknown_formats: false) + end.to raise_error(ArgumentError, /Unknown format/) + end +end + +RSpec.describe JSONSchema::ValidationErrorKind do + it "has name and value for single type error" do + JSONSchema.validate!({ "type" => "string" }, 42) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.kind.name).to eq("type") + expect(e.kind.value).to be_a(Hash) + expect(e.kind.value[:types]).to eq(["string"]) + end + + it "has name and value for multiple type error" do + JSONSchema.validate!({ "type" => %w[string number] }, []) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.kind.name).to eq("type") + expect(e.kind.value[:types]).to eq(%w[number string]) + end + + it "has name and value for required error" do + JSONSchema.validate!({ "type" => "object", "required" => ["name"] }, {}) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.kind.name).to eq("required") + expect(e.kind.value).to be_a(Hash) + expect(e.kind.value).to have_key(:property) + expect(e.kind.value[:property]).to eq("name") + end + + it "returns a hash from to_h" do + JSONSchema.validate!({ "type" => "string" }, 42) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + h = e.kind.to_h + expect(h).to be_a(Hash) + expect(h[:name]).to eq("type") + expect(h[:value]).to be_a(Hash) + end + + it "has inspect and to_s" do + JSONSchema.validate!({ "type" => "string" }, 42) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.kind.inspect).to eq('#') + expect(e.kind.to_s).to eq("type") + end + + it "preserves anyOf context with sub-error details" do + schema = { "anyOf" => [{ "type" => "string" }, { "type" => "integer" }] } + errors = JSONSchema.each_error(schema, []).to_a + expect(errors).not_to be_empty + error = errors.first + expect(error.kind.name).to eq("anyOf") + context = error.kind.value[:context] + expect(context).to be_an(Array) + expect(context).not_to be_empty + # Each branch has sub-errors with detailed fields + sub_error = context.first.first + expect(sub_error).to have_key(:message) + expect(sub_error).to have_key(:instance_path) + expect(sub_error).to have_key(:schema_path) + expect(sub_error).to have_key(:evaluation_path) + expect(sub_error).to have_key(:kind) + expect(sub_error[:kind]).to eq("type") + end + + it "masks anyOf context messages when mask option is used" do + schema = { "anyOf" => [{ "type" => "string" }, { "type" => "integer" }] } + validator = JSONSchema.validator_for(schema, mask: "[HIDDEN]") + expect { validator.validate!(true) }.to raise_error(JSONSchema::ValidationError) do |error| + context = error.kind.value[:context] + messages = context.flatten.map { |entry| entry[:message] } + expect(messages).to all(include("[HIDDEN]")) + expect(messages.join(" ")).not_to include("true") + end + end + + it "preserves oneOf context with sub-error details" do + schema = { "oneOf" => [{ "type" => "string" }, { "type" => "integer" }] } + errors = JSONSchema.each_error(schema, []).to_a + expect(errors).not_to be_empty + error = errors.first + expect(error.kind.name).to eq("oneOf") + context = error.kind.value[:context] + expect(context).to be_an(Array) + sub_error = context.first.first + expect(sub_error).to have_key(:evaluation_path) + expect(sub_error).to have_key(:kind) + end + + it "has name falseSchema for false schema error" do + schema = false + errors = JSONSchema.each_error(schema, "anything").to_a + expect(errors).not_to be_empty + expect(errors.first.kind.name).to eq("falseSchema") + end + + it "has name and value for enum error" do + schema = { "enum" => [1, 2, 3] } + errors = JSONSchema.each_error(schema, 4).to_a + expect(errors).not_to be_empty + error = errors.first + expect(error.kind.name).to eq("enum") + expect(error.kind.value).to have_key(:options) + end + + it "has name and value for minimum error" do + schema = { "type" => "number", "minimum" => 10 } + errors = JSONSchema.each_error(schema, 5).to_a + expect(errors).not_to be_empty + expect(errors.first.kind.name).to eq("minimum") + expect(errors.first.kind.value).to have_key(:limit) + end + + it "has name and value for maxLength error" do + schema = { "type" => "string", "maxLength" => 3 } + errors = JSONSchema.each_error(schema, "toolong").to_a + expect(errors).not_to be_empty + expect(errors.first.kind.name).to eq("maxLength") + expect(errors.first.kind.value[:limit]).to eq(3) + end + + it "has name and value for pattern error" do + schema = { "type" => "string", "pattern" => "^a" } + errors = JSONSchema.each_error(schema, "bbb").to_a + expect(errors).not_to be_empty + expect(errors.first.kind.name).to eq("pattern") + expect(errors.first.kind.value).to have_key(:pattern) + end + + it "has name and value for const error" do + schema = { "const" => "hello" } + errors = JSONSchema.each_error(schema, "world").to_a + expect(errors).not_to be_empty + expect(errors.first.kind.name).to eq("const") + expect(errors.first.kind.value).to have_key(:expected_value) + end + + it "has name uniqueItems for uniqueItems error" do + schema = { "type" => "array", "uniqueItems" => true } + errors = JSONSchema.each_error(schema, [1, 1]).to_a + expect(errors).not_to be_empty + expect(errors.first.kind.name).to eq("uniqueItems") + end +end + +RSpec.describe "Thread safety" do + it "validates concurrently with shared validator" do + schema = { "type" => "object", "properties" => { "name" => { "type" => "string" } }, "required" => ["name"] } + validator = JSONSchema.validator_for(schema) + + threads = 10.times.map do |i| + Thread.new do + 100.times do + if i.even? + expect(validator.valid?({ "name" => "Alice" })).to be true + else + expect(validator.valid?({})).to be false + end + end + end + end + threads.each(&:join) + end +end + +RSpec.describe JSONSchema::ValidationError do + describe "#verbose_message" do + it "returns full context with schema path and instance" do + schema = { "type" => "string" } + begin + JSONSchema.validate!(schema, 42) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.verbose_message).to eq( + "42 is not of type \"string\"\n\nFailed validating \"type\" in schema\n\nOn instance:\n 42" + ) + end + end + + it "includes nested path for nested errors" do + schema = { + "type" => "object", + "properties" => { + "name" => { "type" => "string" } + } + } + begin + JSONSchema.validate!(schema, { "name" => 123 }) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.verbose_message).to eq( + "123 is not of type \"string\"\n\nFailed validating \"type\" in schema[\"properties\"][\"name\"]\n\nOn instance[\"name\"]:\n 123" + ) + end + end + + it "preserves numeric string keys as object properties in paths" do + schema = { + "type" => "object", + "properties" => { + "123" => { "type" => "string" } + } + } + begin + JSONSchema.validate!(schema, { "123" => 42 }) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.verbose_message).to eq( + "42 is not of type \"string\"\n\nFailed validating \"type\" in schema[\"properties\"][\"123\"]\n\nOn instance[\"123\"]:\n 42" + ) + end + end + + it "unescapes JSON Pointer segments in verbose paths" do + schema = { + "type" => "object", + "properties" => { + "a/b~c" => { + "type" => "object", + "properties" => { + "123" => { "type" => "string" } + } + } + } + } + begin + JSONSchema.validate!(schema, { "a/b~c" => { "123" => 42 } }) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.verbose_message).to eq( + "42 is not of type \"string\"\n\nFailed validating \"type\" in schema[\"properties\"][\"a/b~c\"][\"properties\"][\"123\"]\n\nOn instance[\"a/b~c\"][\"123\"]:\n 42" + ) + end + end + + it "masks instance value when mask option is used" do + schema = { "type" => "string" } + validator = JSONSchema.validator_for(schema, mask: "[HIDDEN]") + begin + validator.validate!(42) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.verbose_message).to eq( + "[HIDDEN] is not of type \"string\"\n\nFailed validating \"type\" in schema\n\nOn instance:\n [HIDDEN]" + ) + end + end + end +end + +RSpec.describe "base_uri option" do + it "resolves $ref relative to base_uri" do + schema = { "$ref" => "#/$defs/name", "$defs" => { "name" => { "type" => "string" } } } + validator = JSONSchema.validator_for(schema, base_uri: "http://example.com/schema") + expect(validator.valid?("hello")).to be true + expect(validator.valid?(42)).to be false + end + + it "works with module-level valid?" do + schema = { "$ref" => "#/$defs/name", "$defs" => { "name" => { "type" => "string" } } } + expect(JSONSchema.valid?(schema, "hello", base_uri: "http://example.com/schema")).to be true + end +end + +RSpec.describe JSONSchema::RegexOptions do + it "creates options with defaults" do + opts = JSONSchema::RegexOptions.new + expect(opts.size_limit).to be_nil + expect(opts.dfa_size_limit).to be_nil + end + + it "creates options with custom values" do + opts = JSONSchema::RegexOptions.new(size_limit: 1024, dfa_size_limit: 2048) + expect(opts.size_limit).to eq(1024) + expect(opts.dfa_size_limit).to eq(2048) + end + + it "can be passed as pattern_options" do + opts = JSONSchema::RegexOptions.new(size_limit: 10_000_000) + schema = { "type" => "string", "pattern" => "^test$" } + validator = JSONSchema.validator_for(schema, pattern_options: opts) + expect(validator.valid?("test")).to be true + expect(validator.valid?("other")).to be false + end + + it "has inspect output" do + opts = JSONSchema::RegexOptions.new(size_limit: 1024) + expect(opts.inspect).to eq("#") + end +end + +RSpec.describe JSONSchema::FancyRegexOptions do + it "creates options with defaults" do + opts = JSONSchema::FancyRegexOptions.new + expect(opts.backtrack_limit).to be_nil + expect(opts.size_limit).to be_nil + expect(opts.dfa_size_limit).to be_nil + end + + it "creates options with custom values" do + opts = JSONSchema::FancyRegexOptions.new(backtrack_limit: 1000, size_limit: 2048) + expect(opts.backtrack_limit).to eq(1000) + expect(opts.size_limit).to eq(2048) + end + + it "can be passed as pattern_options" do + opts = JSONSchema::FancyRegexOptions.new(backtrack_limit: 1_000_000) + schema = { "type" => "string", "pattern" => "^test$" } + validator = JSONSchema.validator_for(schema, pattern_options: opts) + expect(validator.valid?("test")).to be true + expect(validator.valid?("other")).to be false + end + + it "has inspect output" do + opts = JSONSchema::FancyRegexOptions.new(backtrack_limit: 1000) + expect(opts.inspect).to eq("#") + end +end + +RSpec.describe JSONSchema::EmailOptions do + it "creates options with defaults" do + opts = JSONSchema::EmailOptions.new + expect(opts.require_tld).to be false + expect(opts.allow_domain_literal).to be true + expect(opts.allow_display_text).to be true + expect(opts.minimum_sub_domains).to be_nil + end + + it "creates options with custom values" do + opts = JSONSchema::EmailOptions.new( + require_tld: false, + allow_domain_literal: true, + allow_display_text: true, + minimum_sub_domains: 2 + ) + expect(opts.require_tld).to be false + expect(opts.allow_domain_literal).to be true + expect(opts.allow_display_text).to be true + expect(opts.minimum_sub_domains).to eq(2) + end + + it "can be passed as email_options" do + opts = JSONSchema::EmailOptions.new(require_tld: false) + schema = { "type" => "string", "format" => "email" } + validator = JSONSchema.validator_for(schema, validate_formats: true, email_options: opts) + expect(validator.valid?("user@localhost")).to be true + end + + it "has inspect output" do + opts = JSONSchema::EmailOptions.new(require_tld: true, minimum_sub_domains: 2) + expect(opts.inspect).to eq("#") + end +end + +RSpec.describe JSONSchema::HttpOptions do + it "creates options with defaults" do + opts = JSONSchema::HttpOptions.new + expect(opts.timeout).to be_nil + expect(opts.connect_timeout).to be_nil + expect(opts.tls_verify).to be true + expect(opts.ca_cert).to be_nil + end + + it "creates options with custom values" do + opts = JSONSchema::HttpOptions.new(timeout: 30.0, connect_timeout: 5.0, tls_verify: false) + expect(opts.timeout).to eq(30.0) + expect(opts.connect_timeout).to eq(5.0) + expect(opts.tls_verify).to be false + end + + it "has inspect output" do + opts = JSONSchema::HttpOptions.new(timeout: 30.0, tls_verify: false) + expect(opts.inspect).to eq("#") + end + + it "raises ArgumentError for negative timeout" do + schema = { "type" => "string" } + opts = JSONSchema::HttpOptions.new(timeout: -1.0) + expect { JSONSchema.validator_for(schema, http_options: opts) } + .to raise_error(ArgumentError, /http_options\.timeout/) + end + + it "raises ArgumentError for non-finite connect_timeout" do + schema = { "type" => "string" } + opts = JSONSchema::HttpOptions.new(connect_timeout: Float::NAN) + expect { JSONSchema.validator_for(schema, http_options: opts) } + .to raise_error(ArgumentError, /http_options\.connect_timeout/) + end + + it "raises ArgumentError for oversized timeout" do + schema = { "type" => "string" } + opts = JSONSchema::HttpOptions.new(timeout: Float::MAX) + expect { JSONSchema.validator_for(schema, http_options: opts) } + .to raise_error(ArgumentError, /http_options\.timeout/) + end +end + +RSpec.describe "Evaluation output structure" do + let(:schema) do + { + "type" => "object", + "properties" => { "name" => { "type" => "string" }, "age" => { "type" => "integer" } }, + "required" => ["name"] + } + end + + describe "#list" do + it "has valid and details keys" do + eval = JSONSchema.evaluate(schema, { "name" => 42 }) + list = eval.list + expect(list).to have_key(:valid) + expect(list).to have_key(:details) + expect(list[:valid]).to be false + expect(list[:details]).to be_an(Array) + expect(list[:details]).not_to be_empty + end + end + + describe "#hierarchical" do + it "has valid key and nested details" do + eval = JSONSchema.evaluate(schema, { "name" => "Alice", "age" => 30 }) + hier = eval.hierarchical + expect(hier).to have_key(:valid) + expect(hier[:valid]).to be true + end + + it "has nested details for invalid instance" do + eval = JSONSchema.evaluate(schema, { "name" => 42 }) + hier = eval.hierarchical + expect(hier[:valid]).to be false + expect(hier).to have_key(:details) + end + end + + describe "#errors" do + it "has complete error structure" do + eval = JSONSchema.evaluate(schema, {}) + errors = eval.errors + expect(errors).not_to be_empty + error = errors.first + expect(error).to have_key(:instanceLocation) + expect(error).to have_key(:schemaLocation) + expect(error).to have_key(:error) + expect(error).to have_key(:absoluteKeywordLocation) + end + end + + describe "#annotations" do + it "has complete annotation structure" do + eval = JSONSchema.evaluate(schema, { "name" => "Alice" }) + annotations = eval.annotations + expect(annotations).to be_an(Array) + next if annotations.empty? + + annotation = annotations.first + expect(annotation).to have_key(:instanceLocation) + expect(annotation).to have_key(:schemaLocation) + expect(annotation).to have_key(:annotations) + end + end +end + +RSpec.describe "evaluation_path_pointer" do + it "returns evaluation path as JSON Pointer" do + schema = { + "type" => "object", + "properties" => { + "name" => { "type" => "string" } + } + } + begin + JSONSchema.validate!(schema, { "name" => 123 }) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.evaluation_path_pointer).to be_a(String) + end + end +end + +RSpec.describe "BigDecimal and large numbers" do + it "validates large integers" do + schema = { "type" => "integer", "minimum" => 0 } + expect(JSONSchema.valid?(schema, 10**100)).to be true + expect(JSONSchema.valid?(schema, -(10**100))).to be false + end + + it "validates large floats" do + schema = { "type" => "number", "minimum" => 0 } + expect(JSONSchema.valid?(schema, 1.0e+308)).to be true + end + + it "handles BigDecimal values" do + require "bigdecimal" + schema = { "type" => "number", "minimum" => 0 } + expect(JSONSchema.valid?(schema, BigDecimal("99999999999999999999.5"))).to be true + expect(JSONSchema.valid?(schema, BigDecimal("-1.5"))).to be false + end + + it "handles BigDecimal values nested in input data" do + require "bigdecimal" + schema = { + "type" => "object", + "properties" => { "price" => { "type" => "number", "minimum" => 0 } }, + "required" => ["price"] + } + expect(JSONSchema.valid?(schema, { "price" => BigDecimal("1234567890.123456789") })).to be true + end + + it "preserves large integer precision in ValidationError#instance" do + value = 10**100 + expect { JSONSchema.validate!({ "type" => "string" }, value) }.to raise_error(JSONSchema::ValidationError) do |error| + expect(error.instance).to eq(value) + expect(error.instance).to be_a(Integer) + end + end + + it "preserves decimal precision in ValidationErrorKind values" do + require "bigdecimal" + expected = BigDecimal("12345678901234567890.12345678901234567890") + expect { JSONSchema.validate!({ "const" => expected }, 0) }.to raise_error(JSONSchema::ValidationError) do |error| + actual = error.kind.value[:expected_value] + expect(actual).to be_a(BigDecimal) + expect(actual.to_s("F")).to eq(expected.to_s("F")) + end + end +end + +RSpec.describe "Symbol-keyed schemas" do + it "validates with symbol keys in schema" do + schema = { type: "string" } + expect(JSONSchema.valid?(schema, "hello")).to be true + expect(JSONSchema.valid?(schema, 42)).to be false + end + + it "validates with nested symbol keys" do + schema = { + type: "object", + properties: { + name: { type: "string" } + }, + required: ["name"] + } + validator = JSONSchema.validator_for(schema) + expect(validator.valid?({ "name" => "Alice" })).to be true + expect(validator.valid?({})).to be false + end + + it "validates with mixed string and symbol keys" do + schema = { "type" => "object", properties: { name: { "type" => "string" } } } + expect(JSONSchema.valid?(schema, { "name" => "Alice" })).to be true + end +end + +RSpec.describe "JSON string schema input" do + describe ".valid?" do + [ + ['{"type":"integer"}', 42, true], + ['{"type":"integer"}', "hello", false], + ['{"type":"string","minLength":3}', "hello", true], + ['{"type":"string","minLength":3}', "hi", false], + ["true", "anything", true], + ["false", "anything", false] + ].each do |(schema, instance, expected)| + it "returns #{expected} for schema=#{schema.inspect}, instance=#{instance.inspect}" do + expect(JSONSchema.valid?(schema, instance)).to eq(expected) + end + end + + # Non-JSON strings fall back to a string value, which is not a valid schema type + it "raises for non-JSON string as schema" do + expect { JSONSchema.valid?("not json", "anything") }.to raise_error(ArgumentError) + end + end + + describe ".validate!" do + it "accepts JSON string schema" do + expect(JSONSchema.validate!('{"type":"string"}', "hello")).to be_nil + end + + it "raises for invalid instance with JSON string schema" do + expect { JSONSchema.validate!('{"type":"string"}', 42) }.to raise_error(JSONSchema::ValidationError) + end + end + + describe ".each_error" do + it "returns errors with JSON string schema" do + errors = JSONSchema.each_error('{"type":"string"}', 42).to_a + expect(errors).not_to be_empty + expect(errors.first).to be_a(JSONSchema::ValidationError) + end + end + + describe ".evaluate" do + it "evaluates with JSON string schema" do + eval = JSONSchema.evaluate('{"type":"string"}', "hello") + expect(eval.valid?).to be true + end + end + + describe ".validator_for" do + it "creates validator from JSON string schema" do + validator = JSONSchema.validator_for('{"type":"integer"}') + expect(validator.valid?(42)).to be true + expect(validator.valid?("hello")).to be false + end + end + + describe "draft-specific validators" do + [ + JSONSchema::Draft4Validator, + JSONSchema::Draft6Validator, + JSONSchema::Draft7Validator, + JSONSchema::Draft201909Validator, + JSONSchema::Draft202012Validator + ].each do |klass| + it "#{klass} accepts JSON string schema" do + validator = klass.new('{"type":"string"}') + expect(validator.valid?("hello")).to be true + expect(validator.valid?(42)).to be false + end + end + end + + describe "Meta" do + it "validates JSON string schema with Meta.valid?" do + expect(JSONSchema::Meta.valid?('{"type":"string"}')).to be true + end + + it "validates JSON string schema with Meta.validate!" do + expect(JSONSchema::Meta.validate!('{"type":"string"}')).to be_nil + end + end +end + +RSpec.describe "Retriever error handling" do + it "raises ArgumentError when retriever returns nil" do + retriever = ->(_uri) {} + schema = { "$ref" => "https://example.com/missing.json" } + expect do + JSONSchema.validator_for(schema, retriever: retriever) + end.to raise_error(ArgumentError, /Retriever returned nil/) + end + + it "raises error when retriever raises" do + retriever = ->(_uri) { raise "Network error" } + schema = { "$ref" => "https://example.com/failing.json" } + expect do + JSONSchema.validator_for(schema, retriever: retriever) + end.to raise_error(ArgumentError, /Network error/) + end + + it "handles circular $ref in retrieved schemas" do + retriever = lambda do |uri| + case uri + when "https://example.com/person.json" + { + "type" => "object", + "properties" => { + "name" => { "type" => "string" }, + "friend" => { "$ref" => "https://example.com/person.json" } + }, + "required" => ["name"] + } + end + end + + validator = JSONSchema.validator_for( + { "$ref" => "https://example.com/person.json" }, + retriever: retriever + ) + expect(validator.valid?({ "name" => "Alice", "friend" => { "name" => "Bob" } })).to be true + expect(validator.valid?({ "name" => "Alice", "friend" => {} })).to be false + end + + it "resolves relative $ref paths via base_uri" do + retriever = lambda do |uri| + case uri + when "https://example.com/schemas/b.json" + { "type" => "string" } + end + end + + validator = JSONSchema.validator_for( + { "$ref" => "./b.json" }, + base_uri: "https://example.com/schemas/a.json", + retriever: retriever + ) + expect(validator.valid?("hello")).to be true + expect(validator.valid?(42)).to be false + end +end + +RSpec.describe "Custom keywords" do + it "passes correct arguments to keyword initializer" do + received = nil + keyword_class = Class.new do + define_method(:initialize) do |parent_schema, value, schema_path| + received = { parent_schema: parent_schema, value: value, schema_path: schema_path } + end + define_method(:validate) { |_instance| nil } + end + + schema = { "type" => "integer", "myKeyword" => 42 } + JSONSchema.validator_for(schema, keywords: { "myKeyword" => keyword_class }) + + expect(received[:parent_schema]).to eq(schema) + expect(received[:value]).to eq(42) + expect(received[:schema_path]).to eq(["myKeyword"]) + end +end + +RSpec.describe JSONSchema::ValidationError do + describe "#instance for basic types" do + { + "string" => ["hello", { "type" => "integer" }], + "hash" => [{ "a" => 1 }, { "type" => "string" }], + "array" => [[1, 2], { "type" => "string" }], + "nil" => [nil, { "type" => "string" }], + "boolean" => [true, { "type" => "string" }] + }.each do |type_name, (value, schema)| + it "preserves #{type_name} instance value" do + expect { JSONSchema.validate!(schema, value) } + .to raise_error(JSONSchema::ValidationError) { |e| expect(e.instance).to eq(value) } + end + end + end + + describe "#evaluation_path" do + it "returns evaluation path as array and JSON Pointer" do + schema = { + "type" => "object", + "properties" => { "name" => { "type" => "string" } } + } + begin + JSONSchema.validate!(schema, { "name" => 123 }) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.evaluation_path).to be_an(Array) + expect(e.evaluation_path).not_to be_empty + expect(e.evaluation_path_pointer).to be_a(String) + expect(e.evaluation_path_pointer).not_to be_empty + end + end + end + + describe "#inspect" do + it "returns formatted inspection string" do + error = JSONSchema.each_error({ "type" => "string" }, 42).first + expect(error.inspect).to eq('#') + end + + it "falls back to the exception message when constructed in Ruby" do + error = JSONSchema::ValidationError.new("msg") + expect(error.message).to eq("msg") + expect(error.inspect).to eq("#") + end + end + + describe "#to_s" do + it "returns the message" do + error = JSONSchema.each_error({ "type" => "string" }, 42).first + expect(error.to_s).to eq(error.message) + end + end +end + +RSpec.describe "Boolean schemas" do + it "validates with boolean true schema" do + expect(JSONSchema.valid?(true, "anything")).to be true + expect(JSONSchema.valid?(true, 42)).to be true + end + + it "rejects all instances with boolean false schema" do + expect(JSONSchema.valid?(false, "anything")).to be false + end +end + +RSpec.describe "draft: keyword argument on module-level functions" do + it "accepts draft on valid?" do + schema = { "type" => "string" } + expect(JSONSchema.valid?(schema, "hello", draft: :draft7)).to be true + expect(JSONSchema.valid?(schema, 42, draft: :draft4)).to be false + end + + it "accepts draft on validate!" do + schema = { "type" => "string" } + expect(JSONSchema.validate!(schema, "hello", draft: :draft7)).to be_nil + end + + it "accepts draft on each_error" do + schema = { "type" => "string" } + errors = JSONSchema.each_error(schema, 42, draft: :draft7) + expect(errors).not_to be_empty + end + + it "accepts draft on evaluate" do + schema = { "type" => "string" } + result = JSONSchema.evaluate(schema, "hello", draft: :draft7) + expect(result.valid?).to be true + end +end + +RSpec.describe "Type coercion errors" do + it "raises for NaN as instance value" do + expect { JSONSchema.valid?({ "type" => "number" }, Float::NAN) } + .to raise_error(ArgumentError, /NaN/) + end + + it "raises for Infinity as instance value" do + expect { JSONSchema.valid?({ "type" => "number" }, Float::INFINITY) } + .to raise_error(ArgumentError, /Infinity/) + end + + it "raises for unsupported types" do + expect { JSONSchema.valid?({ "type" => "string" }, /regex/) } + .to raise_error(TypeError, /Unsupported type/) + end +end + +RSpec.describe "Invalid option types" do + it "raises TypeError for invalid pattern_options" do + expect { JSONSchema.validator_for({ "type" => "string" }, pattern_options: "bad") } + .to raise_error(TypeError, /pattern_options must be/) + end + + it "raises TypeError for invalid email_options" do + expect { JSONSchema.validator_for({ "type" => "string" }, email_options: "bad") } + .to raise_error(TypeError, /email_options must be/) + end + + it "raises TypeError for invalid http_options" do + expect { JSONSchema.validator_for({ "type" => "string" }, http_options: "bad") } + .to raise_error(TypeError, /http_options must be/) + end + + it "raises TypeError for invalid registry" do + expect { JSONSchema.validator_for({ "type" => "string" }, registry: "bad") } + .to raise_error(TypeError, /registry must be/) + end +end + +RSpec.describe "Custom keywords" do + it "raises error for keyword class missing validate method" do + klass = Class.new do + def initialize(parent_schema, value, schema_path); end + end + expect { JSONSchema.validator_for({ "myKeyword" => true }, keywords: { "myKeyword" => klass }) } + .to raise_error(TypeError, /must define a 'validate' instance method/) + end +end + +RSpec.describe "Custom formats" do + it "raises TypeError for non-callable format checker" do + expect do + JSONSchema.validator_for( + { "type" => "string", "format" => "my-format" }, + validate_formats: true, + formats: { "my-format" => "not callable" } + ) + end.to raise_error(TypeError, /must be a callable/) + end +end + +RSpec.describe JSONSchema::Registry do + it "raises for invalid resource pair" do + expect { JSONSchema::Registry.new([[1]]) } + .to raise_error(ArgumentError, /must be a \[uri, schema\] pair/) + end + + it "accepts draft keyword argument" do + registry = JSONSchema::Registry.new( + [["https://example.com/s.json", { "type" => "string" }]], + draft: :draft7 + ) + validator = JSONSchema.validator_for( + { "$ref" => "https://example.com/s.json" }, + registry: registry + ) + expect(validator.valid?("hello")).to be true + end + + it "keeps retriever callback alive while resolving refs during construction" do + 100.times do |iteration| + retrieved_uris = [] + retriever = lambda do |uri| + retrieved_uris << uri + case uri + when "https://example.com/first.json" + GC.start(full_mark: true, immediate_sweep: true) + GC.compact if GC.respond_to?(:compact) + { "$ref" => "https://example.com/second.json" } + when "https://example.com/second.json" + { "type" => "string" } + else + raise "Unexpected URI: #{uri}" + end + end + retriever_ref = WeakRef.new(retriever) + registry = JSONSchema::Registry.new( + [["https://example.com/root.json", { "$ref" => "https://example.com/first.json" }]], + retriever: retriever + ) + + expect(retrieved_uris).to eq(["https://example.com/first.json", "https://example.com/second.json"]), + "retriever failed during construction on iteration #{iteration}" + expect(retriever_ref.weakref_alive?).to be(true), + "retriever proc was collected during construction on iteration #{iteration}" + expect( + JSONSchema.valid?({ "$ref" => "https://example.com/root.json" }, "hello", registry: registry) + ).to be true + end + end + + it "has inspect output" do + registry = JSONSchema::Registry.new([]) + expect(registry.inspect).to eq("#") + end +end + +RSpec.describe "draft: symbol validation errors" do + it "raises TypeError for non-symbol draft" do + expect { JSONSchema.valid?({ "type" => "string" }, "hello", draft: 7) } + .to raise_error(TypeError, /draft must be a Symbol/) + end + + it "raises ArgumentError for unknown draft symbol" do + expect { JSONSchema.valid?({ "type" => "string" }, "hello", draft: :draft99) } + .to raise_error(ArgumentError, /Unknown draft/) + end +end + +RSpec.describe "Masking" do + it "masks nested object values" do + schema = { + "type" => "object", + "properties" => { + "user" => { + "type" => "object", + "properties" => { "name" => { "type" => "string" } } + } + } + } + validator = JSONSchema.validator_for(schema, mask: "[MASKED]") + begin + validator.validate!({ "user" => { "name" => 42 } }) + raise "Expected ValidationError" + rescue JSONSchema::ValidationError => e + expect(e.message).not_to include("42") + expect(e.message).to include("[MASKED]") + end + end + + it "masks array item values" do + schema = { "type" => "array", "items" => { "type" => "string" } } + validator = JSONSchema.validator_for(schema, mask: "[MASKED]") + errors = validator.each_error([42]).to_a + expect(errors).not_to be_empty + errors.each do |error| + expect(error.message).not_to include("42") + expect(error.message).to include("[MASKED]") + end + end +end diff --git a/crates/jsonschema-rb/spec/readme_spec.rb b/crates/jsonschema-rb/spec/readme_spec.rb new file mode 100644 index 000000000..24a94f166 --- /dev/null +++ b/crates/jsonschema-rb/spec/readme_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "README examples" do + readme_path = File.expand_path("../README.md", __dir__) + readme = File.read(readme_path) + + code_blocks = readme.scan(/```ruby\n(.*?)```/m).flatten + + # Skip blocks that aren't executable Ruby code: + # - Gemfile syntax (`gem 'jsonschema'`) + # - Options reference with `...` placeholder + skip_patterns = [ + /\Agem\s+['"]/, # Gemfile syntax + /\.\.\./ # Documentation placeholder + ] + + scope = binding + + code_blocks.each_with_index do |code, index| + should_skip = skip_patterns.any? { |pattern| code.match?(pattern) } + + if should_skip + it "code block #{index + 1} is skipped (non-executable)" do + skip "Block contains non-executable syntax" + end + else + it "code block #{index + 1} executes without error" do + scope.eval(code, "README.md block #{index + 1}", 1) + end + end + end +end diff --git a/crates/jsonschema-rb/spec/spec_helper.rb b/crates/jsonschema-rb/spec/spec_helper.rb new file mode 100644 index 000000000..58c3ea656 --- /dev/null +++ b/crates/jsonschema-rb/spec/spec_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "jsonschema" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.disable_monkey_patching! + config.warnings = true + + config.default_formatter = "doc" if config.files_to_run.one? +end diff --git a/crates/jsonschema-rb/spec/suite_spec.rb b/crates/jsonschema-rb/spec/suite_spec.rb new file mode 100644 index 000000000..f5f591068 --- /dev/null +++ b/crates/jsonschema-rb/spec/suite_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "spec_helper" +require "json" +require "pathname" + +module SuiteHelpers + SUITE_PATH = Pathname.new(__dir__).join("../../jsonschema/tests/suite/tests") + REMOTES_PATH = Pathname.new(__dir__).join("../../jsonschema/tests/suite/remotes") + + # Map draft directories to JSONSchema draft constants + DRAFT_MAP = { + "draft4" => :draft4, + "draft6" => :draft6, + "draft7" => :draft7, + "draft2019-09" => :draft201909, + "draft2020-12" => :draft202012 + }.freeze + + def self.sanitize_name(name) + name.gsub(/[^a-zA-Z0-9_]/, "_").gsub(/_+/, "_").gsub(/^_|_$/, "") + end + + # Build a retriever proc for remote schemas + def self.build_retriever + return @build_retriever if defined?(@build_retriever) + + remotes = {} + if REMOTES_PATH.exist? + REMOTES_PATH.glob("**/*.json").each do |file| + relative = file.relative_path_from(REMOTES_PATH).to_s + uri = "http://localhost:1234/#{relative}" + # Parse JSON to return Ruby hashes + remotes[uri] = JSON.parse(file.read) + end + end + + @build_retriever = ->(uri) { remotes[uri] } + end +end + +RSpec.describe "JSON Schema Test Suite" do + SuiteHelpers::DRAFT_MAP.each do |draft_name, draft_const| + draft_path = SuiteHelpers::SUITE_PATH.join(draft_name) + next unless draft_path.exist? + + describe draft_name do + draft_path.glob("**/*.json").sort.each do |test_file| + relative_path = test_file.relative_path_from(draft_path).to_s.sub(/\.json$/, "") + is_optional = relative_path.start_with?("optional/") + + context relative_path do + test_cases = JSON.parse(test_file.read) + + test_cases.each do |test_case| + case_description = test_case["description"] + schema = test_case["schema"] + + context case_description do + test_case["tests"].each do |test| + test_description = test["description"] + data = test["data"] + expected_valid = test["valid"] + + it test_description do + opts = { + draft: draft_const, + validate_formats: is_optional, + retriever: SuiteHelpers.build_retriever + } + error_ctx = "Schema: #{JSON.pretty_generate(schema)}\n" \ + "Instance: #{JSON.pretty_generate(data)}" + + # valid? + result = JSONSchema.valid?(schema, data, **opts) + if expected_valid + expect(result).to be(true), + "valid? expected true but got false.\n#{error_ctx}" + else + expect(result).to be(false), + "valid? expected false but got true.\n#{error_ctx}" + end + + # validate! + if expected_valid + expect { JSONSchema.validate!(schema, data, **opts) }.not_to raise_error + else + expect { JSONSchema.validate!(schema, data, **opts) }.to raise_error(JSONSchema::ValidationError) + end + + # each_error + errors = JSONSchema.each_error(schema, data, **opts) + if expected_valid + expect(errors).to be_empty, + "each_error expected no errors.\n#{error_ctx}" + else + expect(errors).not_to be_empty, + "each_error expected errors but got none.\n#{error_ctx}" + end + + # evaluate + eval_result = JSONSchema.evaluate(schema, data, **opts) + expect(eval_result.valid?).to eq(expected_valid), + "evaluate.valid? expected #{expected_valid}.\n#{error_ctx}" + end + end + end + end + end + end + end + end +end diff --git a/crates/jsonschema-rb/src/error_kind.rs b/crates/jsonschema-rb/src/error_kind.rs new file mode 100644 index 000000000..21f02ce1c --- /dev/null +++ b/crates/jsonschema-rb/src/error_kind.rs @@ -0,0 +1,356 @@ +//! ValidationErrorKind enum for Ruby. +use magnus::{ + gc, method, + prelude::*, + value::{Opaque, StaticSymbol}, + Error, RModule, Ruby, TypedData, Value, +}; + +use crate::{ser::value_to_ruby, static_id::define_rb_intern}; + +define_rb_intern!(static ID_NAME: "name"); +define_rb_intern!(static ID_VALUE: "value"); +define_rb_intern!(static ID_LIMIT: "limit"); +define_rb_intern!(static ID_UNEXPECTED: "unexpected"); +define_rb_intern!(static ID_CONTEXT: "context"); +define_rb_intern!(static ID_ERROR: "error"); +define_rb_intern!(static ID_EXPECTED_VALUE: "expected_value"); +define_rb_intern!(static ID_CONTENT_ENCODING: "content_encoding"); +define_rb_intern!(static ID_CONTENT_MEDIA_TYPE: "content_media_type"); +define_rb_intern!(static ID_MESSAGE: "message"); +define_rb_intern!(static ID_OPTIONS: "options"); +define_rb_intern!(static ID_FORMAT: "format"); +define_rb_intern!(static ID_MULTIPLE_OF: "multiple_of"); +define_rb_intern!(static ID_SCHEMA: "schema"); +define_rb_intern!(static ID_REASON: "reason"); +define_rb_intern!(static ID_PROPERTY: "property"); +define_rb_intern!(static ID_TYPES: "types"); +define_rb_intern!(static ID_PATTERN: "pattern"); +define_rb_intern!(static ID_CTX_INSTANCE_PATH: "instance_path"); +define_rb_intern!(static ID_CTX_SCHEMA_PATH: "schema_path"); +define_rb_intern!(static ID_CTX_EVALUATION_PATH: "evaluation_path"); +define_rb_intern!(static ID_CTX_KIND: "kind"); + +#[derive(TypedData)] +#[magnus( + class = "JSONSchema::ValidationErrorKind", + free_immediately, + size, + mark +)] +pub struct ValidationErrorKind { + name: String, + data: Opaque, +} + +impl magnus::typed_data::DataTypeFunctions for ValidationErrorKind { + fn mark(&self, marker: &gc::Marker) { + marker.mark(self.data); + } +} + +#[inline] +fn rb_hash1(ruby: &Ruby, k1: StaticSymbol, v1: Value) -> Result { + let hash = ruby.hash_new_capa(1); + hash.aset(k1, v1)?; + Ok(hash.as_value()) +} + +#[inline] +fn rb_hash2( + ruby: &Ruby, + k1: StaticSymbol, + v1: Value, + k2: StaticSymbol, + v2: Value, +) -> Result { + let hash = ruby.hash_new_capa(2); + hash.aset(k1, v1)?; + hash.aset(k2, v2)?; + Ok(hash.as_value()) +} + +/// Convert anyOf/oneOf context into a Ruby array of error branch arrays. +fn context_to_ruby( + ruby: &Ruby, + context: &[Vec>], + mask: Option<&str>, +) -> Result { + let sym_message = ID_MESSAGE.to_symbol(); + let sym_instance_path = ID_CTX_INSTANCE_PATH.to_symbol(); + let sym_schema_path = ID_CTX_SCHEMA_PATH.to_symbol(); + let sym_evaluation_path = ID_CTX_EVALUATION_PATH.to_symbol(); + let sym_kind = ID_CTX_KIND.to_symbol(); + + let branches = ruby.ary_new_capa(context.len()); + for branch in context { + let errors = ruby.ary_new_capa(branch.len()); + for e in branch { + let hash = ruby.hash_new_capa(5); + let message = if let Some(mask) = mask { + e.masked_with(mask).to_string() + } else { + e.to_string() + }; + hash.aset(sym_message, ruby.into_value(message.as_str()))?; + hash.aset( + sym_instance_path, + ruby.into_value(e.instance_path().as_str()), + )?; + hash.aset(sym_schema_path, ruby.into_value(e.schema_path().as_str()))?; + hash.aset( + sym_evaluation_path, + ruby.into_value(e.evaluation_path().as_str()), + )?; + hash.aset(sym_kind, ruby.into_value(keyword(e.kind())))?; + errors.push(hash)?; + } + branches.push(errors)?; + } + Ok(branches.as_value()) +} + +fn strings_to_ruby(ruby: &Ruby, strings: &[String]) -> Value { + ruby.ary_from_iter(strings.iter().map(|s| ruby.into_value(s.as_str()))) + .as_value() +} + +// TODO: Replace with `JsonType::as_str()` once we depend on a version that exposes it. +fn json_type_as_str(ty: &jsonschema::JsonType) -> &'static str { + use jsonschema::JsonType; + match ty { + JsonType::Array => "array", + JsonType::Boolean => "boolean", + JsonType::Integer => "integer", + JsonType::Null => "null", + JsonType::Number => "number", + JsonType::Object => "object", + JsonType::String => "string", + } +} + +fn keyword(kind: &jsonschema::error::ValidationErrorKind) -> &str { + use jsonschema::error::ValidationErrorKind as K; + match kind { + K::AdditionalItems { .. } => "additionalItems", + K::AdditionalProperties { .. } => "additionalProperties", + K::AnyOf { .. } => "anyOf", + K::BacktrackLimitExceeded { .. } | K::Pattern { .. } => "pattern", + K::Constant { .. } => "const", + K::Contains => "contains", + K::ContentEncoding { .. } | K::FromUtf8 { .. } => "contentEncoding", + K::ContentMediaType { .. } => "contentMediaType", + K::Custom { keyword, .. } => keyword, + K::Enum { .. } => "enum", + K::ExclusiveMaximum { .. } => "exclusiveMaximum", + K::ExclusiveMinimum { .. } => "exclusiveMinimum", + K::FalseSchema => "falseSchema", + K::Format { .. } => "format", + K::MaxItems { .. } => "maxItems", + K::Maximum { .. } => "maximum", + K::MaxLength { .. } => "maxLength", + K::MaxProperties { .. } => "maxProperties", + K::MinItems { .. } => "minItems", + K::Minimum { .. } => "minimum", + K::MinLength { .. } => "minLength", + K::MinProperties { .. } => "minProperties", + K::MultipleOf { .. } => "multipleOf", + K::Not { .. } => "not", + K::OneOfMultipleValid { .. } | K::OneOfNotValid { .. } => "oneOf", + K::PropertyNames { .. } => "propertyNames", + K::Required { .. } => "required", + K::Type { .. } => "type", + K::UnevaluatedItems { .. } => "unevaluatedItems", + K::UnevaluatedProperties { .. } => "unevaluatedProperties", + K::UniqueItems => "uniqueItems", + K::Referencing(_) => "$ref", + } +} + +impl ValidationErrorKind { + pub fn new( + ruby: &Ruby, + kind: &jsonschema::error::ValidationErrorKind, + mask: Option<&str>, + ) -> Result { + use jsonschema::error::ValidationErrorKind as K; + + let name = keyword(kind); + + let data = match kind { + K::AdditionalItems { limit } => rb_hash1( + ruby, + ID_LIMIT.to_symbol(), + ruby.integer_from_u64(*limit as u64).as_value(), + )?, + K::AdditionalProperties { unexpected } + | K::UnevaluatedItems { unexpected } + | K::UnevaluatedProperties { unexpected } => rb_hash1( + ruby, + ID_UNEXPECTED.to_symbol(), + strings_to_ruby(ruby, unexpected), + )?, + K::AnyOf { context } => rb_hash1( + ruby, + ID_CONTEXT.to_symbol(), + context_to_ruby(ruby, context, mask)?, + )?, + K::BacktrackLimitExceeded { error } => rb_hash1( + ruby, + ID_ERROR.to_symbol(), + ruby.into_value(error.to_string().as_str()), + )?, + K::Constant { expected_value } => rb_hash1( + ruby, + ID_EXPECTED_VALUE.to_symbol(), + value_to_ruby(ruby, expected_value)?, + )?, + K::Contains | K::FalseSchema | K::UniqueItems => ruby.hash_new().as_value(), + K::ContentEncoding { content_encoding } => rb_hash1( + ruby, + ID_CONTENT_ENCODING.to_symbol(), + ruby.into_value(content_encoding.as_str()), + )?, + K::ContentMediaType { content_media_type } => rb_hash1( + ruby, + ID_CONTENT_MEDIA_TYPE.to_symbol(), + ruby.into_value(content_media_type.as_str()), + )?, + K::Custom { message, .. } => rb_hash1( + ruby, + ID_MESSAGE.to_symbol(), + ruby.into_value(message.as_str()), + )?, + K::Enum { options } => { + rb_hash1(ruby, ID_OPTIONS.to_symbol(), value_to_ruby(ruby, options)?)? + } + K::ExclusiveMaximum { limit } + | K::ExclusiveMinimum { limit } + | K::Maximum { limit } + | K::Minimum { limit } => { + rb_hash1(ruby, ID_LIMIT.to_symbol(), value_to_ruby(ruby, limit)?)? + } + K::Format { format } => rb_hash1( + ruby, + ID_FORMAT.to_symbol(), + ruby.into_value(format.as_str()), + )?, + K::FromUtf8 { error } => rb_hash1( + ruby, + ID_ERROR.to_symbol(), + ruby.into_value(error.to_string().as_str()), + )?, + K::MaxItems { limit } + | K::MaxLength { limit } + | K::MaxProperties { limit } + | K::MinItems { limit } + | K::MinLength { limit } + | K::MinProperties { limit } => rb_hash1( + ruby, + ID_LIMIT.to_symbol(), + ruby.integer_from_u64(*limit).as_value(), + )?, + K::MultipleOf { multiple_of } => rb_hash1( + ruby, + ID_MULTIPLE_OF.to_symbol(), + value_to_ruby(ruby, multiple_of)?, + )?, + K::Not { schema } => { + rb_hash1(ruby, ID_SCHEMA.to_symbol(), value_to_ruby(ruby, schema)?)? + } + K::OneOfMultipleValid { context } => rb_hash2( + ruby, + ID_REASON.to_symbol(), + ruby.into_value("multipleValid"), + ID_CONTEXT.to_symbol(), + context_to_ruby(ruby, context, mask)?, + )?, + K::OneOfNotValid { context } => rb_hash2( + ruby, + ID_REASON.to_symbol(), + ruby.into_value("notValid"), + ID_CONTEXT.to_symbol(), + context_to_ruby(ruby, context, mask)?, + )?, + K::Pattern { pattern } => rb_hash1( + ruby, + ID_PATTERN.to_symbol(), + ruby.into_value(pattern.as_str()), + )?, + K::PropertyNames { error } => { + let message = if let Some(mask) = mask { + error.masked_with(mask).to_string() + } else { + error.to_string() + }; + rb_hash1( + ruby, + ID_ERROR.to_symbol(), + ruby.into_value(message.as_str()), + )? + } + K::Referencing(err) => rb_hash1( + ruby, + ID_ERROR.to_symbol(), + ruby.into_value(err.to_string().as_str()), + )?, + K::Required { property } => rb_hash1( + ruby, + ID_PROPERTY.to_symbol(), + value_to_ruby(ruby, property)?, + )?, + K::Type { kind } => { + let types: Vec = match kind { + jsonschema::error::TypeKind::Single(ty) => { + vec![ruby.into_value(json_type_as_str(ty))] + } + jsonschema::error::TypeKind::Multiple(types) => types + .iter() + .map(|ty| ruby.into_value(json_type_as_str(&ty))) + .collect(), + }; + let rb_types = ruby.ary_from_iter(types); + rb_hash1(ruby, ID_TYPES.to_symbol(), rb_types.as_value())? + } + }; + + Ok(ValidationErrorKind { + name: name.to_string(), + data: data.into(), + }) + } + + fn name(&self) -> &str { + &self.name + } + + fn value(ruby: &Ruby, rb_self: &Self) -> Value { + ruby.get_inner(rb_self.data) + } + + fn as_hash(ruby: &Ruby, rb_self: &Self) -> Result { + let hash = ruby.hash_new_capa(2); + hash.aset(ID_NAME.to_symbol(), rb_self.name.as_str())?; + hash.aset(ID_VALUE.to_symbol(), ruby.get_inner(rb_self.data))?; + Ok(hash.as_value()) + } + + fn inspect(&self) -> String { + format!("#", self.name) + } + + fn to_s(&self) -> &str { + &self.name + } +} + +pub fn define_class(ruby: &Ruby, module: &RModule) -> Result<(), Error> { + let class = module.define_class("ValidationErrorKind", ruby.class_object())?; + class.define_method("name", method!(ValidationErrorKind::name, 0))?; + class.define_method("value", method!(ValidationErrorKind::value, 0))?; + class.define_method("to_h", method!(ValidationErrorKind::as_hash, 0))?; + class.define_method("inspect", method!(ValidationErrorKind::inspect, 0))?; + class.define_method("to_s", method!(ValidationErrorKind::to_s, 0))?; + + Ok(()) +} diff --git a/crates/jsonschema-rb/src/evaluation.rs b/crates/jsonschema-rb/src/evaluation.rs new file mode 100644 index 000000000..e6215873c --- /dev/null +++ b/crates/jsonschema-rb/src/evaluation.rs @@ -0,0 +1,116 @@ +//! Evaluation output wrapper for Ruby +//! +//! Provides full JSON Schema output format support: flag, list, and hierarchical. +use magnus::{method, prelude::*, Error, RModule, Ruby, Value}; + +use crate::{ + ser::{serialize_to_ruby, value_to_ruby}, + static_id::define_rb_intern, +}; + +define_rb_intern!(static ID_SCHEMA_LOCATION: "schemaLocation"); +define_rb_intern!(static ID_ABSOLUTE_KEYWORD_LOCATION: "absoluteKeywordLocation"); +define_rb_intern!(static ID_INSTANCE_LOCATION: "instanceLocation"); +define_rb_intern!(static ID_ANNOTATIONS: "annotations"); +define_rb_intern!(static ID_ERROR: "error"); +define_rb_intern!(static ID_VALID: "valid"); + +#[magnus::wrap(class = "JSONSchema::Evaluation", free_immediately, size)] +pub struct Evaluation { + inner: jsonschema::Evaluation, +} + +impl Evaluation { + pub fn new(output: jsonschema::Evaluation) -> Self { + Evaluation { inner: output } + } + + fn is_valid(&self) -> bool { + self.inner.flag().valid + } + + /// Simplest output format โ€” only a "valid" key. + fn flag(ruby: &Ruby, rb_self: &Self) -> Result { + let flag_output = rb_self.inner.flag(); + let hash = ruby.hash_new_capa(1); + hash.aset(ID_VALID.to_symbol(), flag_output.valid)?; + Ok(hash.as_value()) + } + + /// Flat list of all evaluation results with annotations and errors. + fn list(ruby: &Ruby, rb_self: &Self) -> Result { + let list_output = rb_self.inner.list(); + serialize_to_ruby(ruby, &list_output) + } + + /// Nested tree structure following the schema structure. + fn hierarchical(ruby: &Ruby, rb_self: &Self) -> Result { + let hierarchical_output = rb_self.inner.hierarchical(); + serialize_to_ruby(ruby, &hierarchical_output) + } + + fn annotations(ruby: &Ruby, rb_self: &Self) -> Result { + let schema_loc = ID_SCHEMA_LOCATION.to_symbol(); + let abs_kw_loc = ID_ABSOLUTE_KEYWORD_LOCATION.to_symbol(); + let inst_loc = ID_INSTANCE_LOCATION.to_symbol(); + let annotations_key = ID_ANNOTATIONS.to_symbol(); + let arr = ruby.ary_new(); + for entry in rb_self.inner.iter_annotations() { + let hash = ruby.hash_new_capa(4); + hash.aset(schema_loc, entry.schema_location)?; + if let Some(uri) = entry.absolute_keyword_location { + hash.aset(abs_kw_loc, uri.as_str())?; + } else { + hash.aset(abs_kw_loc, ruby.qnil())?; + } + hash.aset(inst_loc, entry.instance_location.as_str())?; + hash.aset( + annotations_key, + value_to_ruby(ruby, entry.annotations.value())?, + )?; + arr.push(hash)?; + } + Ok(arr.as_value()) + } + + fn errors(ruby: &Ruby, rb_self: &Self) -> Result { + let schema_loc = ID_SCHEMA_LOCATION.to_symbol(); + let abs_kw_loc = ID_ABSOLUTE_KEYWORD_LOCATION.to_symbol(); + let inst_loc = ID_INSTANCE_LOCATION.to_symbol(); + let error_key = ID_ERROR.to_symbol(); + let arr = ruby.ary_new(); + for entry in rb_self.inner.iter_errors() { + let hash = ruby.hash_new_capa(4); + hash.aset(schema_loc, entry.schema_location)?; + if let Some(uri) = entry.absolute_keyword_location { + hash.aset(abs_kw_loc, uri.as_str())?; + } else { + hash.aset(abs_kw_loc, ruby.qnil())?; + } + hash.aset(inst_loc, entry.instance_location.as_str())?; + hash.aset(error_key, entry.error.to_string())?; + arr.push(hash)?; + } + Ok(arr.as_value()) + } + + fn inspect(&self) -> String { + format!( + "#", + self.inner.flag().valid + ) + } +} + +pub fn define_class(ruby: &Ruby, module: &RModule) -> Result<(), Error> { + let class = module.define_class("Evaluation", ruby.class_object())?; + class.define_method("valid?", method!(Evaluation::is_valid, 0))?; + class.define_method("flag", method!(Evaluation::flag, 0))?; + class.define_method("list", method!(Evaluation::list, 0))?; + class.define_method("hierarchical", method!(Evaluation::hierarchical, 0))?; + class.define_method("annotations", method!(Evaluation::annotations, 0))?; + class.define_method("errors", method!(Evaluation::errors, 0))?; + class.define_method("inspect", method!(Evaluation::inspect, 0))?; + + Ok(()) +} diff --git a/crates/jsonschema-rb/src/lib.rs b/crates/jsonschema-rb/src/lib.rs new file mode 100644 index 000000000..a5010de23 --- /dev/null +++ b/crates/jsonschema-rb/src/lib.rs @@ -0,0 +1,1349 @@ +//! Ruby bindings for the jsonschema crate. +#![allow(unreachable_pub)] +#![allow(clippy::trivially_copy_pass_by_ref)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::unused_self)] +#![allow(clippy::struct_field_names)] + +mod error_kind; +mod evaluation; +mod options; +mod registry; +mod retriever; +mod ser; +mod static_id; + +use jsonschema::{paths::LocationSegment, ValidationOptions}; +use magnus::{ + function, + gc::{register_address, register_mark_object, unregister_address}, + method, + prelude::*, + scan_args::scan_args, + value::{Lazy, ReprValue}, + DataTypeFunctions, Error, Exception, ExceptionClass, RClass, RModule, RObject, Ruby, Value, +}; +use referencing::unescape_segment; +use std::{ + cell::RefCell, + panic::{self, AssertUnwindSafe}, + sync::Arc, +}; + +use crate::{ + error_kind::ValidationErrorKind, + evaluation::Evaluation, + options::{ + extract_evaluate_kwargs, extract_kwargs, extract_kwargs_no_draft, make_options_from_kwargs, + parse_draft_symbol, CallbackRoots, CompilationRoots, CompilationRootsRef, ExtractedKwargs, + ParsedOptions, + }, + registry::Registry, + retriever::{retriever_error_message, RubyRetriever}, + ser::{to_schema_value, to_value}, + static_id::define_rb_intern, +}; + +// Report Rust heap usage to Ruby GC so it can account for native memory pressure. +rb_sys::set_global_tracking_allocator!(); + +define_rb_intern!(static ID_ALLOCATE: "allocate"); +define_rb_intern!(static ID_AT_MESSAGE: "@message"); +define_rb_intern!(static ID_AT_VERBOSE_MESSAGE: "@verbose_message"); +define_rb_intern!(static ID_AT_INSTANCE_PATH: "@instance_path"); +define_rb_intern!(static ID_AT_SCHEMA_PATH: "@schema_path"); +define_rb_intern!(static ID_AT_EVALUATION_PATH: "@evaluation_path"); +define_rb_intern!(static ID_AT_INSTANCE_PATH_POINTER: "@instance_path_pointer"); +define_rb_intern!(static ID_AT_SCHEMA_PATH_POINTER: "@schema_path_pointer"); +define_rb_intern!(static ID_AT_EVALUATION_PATH_POINTER: "@evaluation_path_pointer"); +define_rb_intern!(static ID_AT_KIND: "@kind"); +define_rb_intern!(static ID_AT_INSTANCE: "@instance"); + +define_rb_intern!(static ID_SYM_MESSAGE: "message"); +define_rb_intern!(static ID_SYM_VERBOSE_MESSAGE: "verbose_message"); +define_rb_intern!(static ID_SYM_INSTANCE_PATH: "instance_path"); +define_rb_intern!(static ID_SYM_SCHEMA_PATH: "schema_path"); +define_rb_intern!(static ID_SYM_EVALUATION_PATH: "evaluation_path"); +define_rb_intern!(static ID_SYM_KIND: "kind"); +define_rb_intern!(static ID_SYM_INSTANCE: "instance"); +define_rb_intern!(static ID_SYM_INSTANCE_PATH_POINTER: "instance_path_pointer"); +define_rb_intern!(static ID_SYM_SCHEMA_PATH_POINTER: "schema_path_pointer"); +define_rb_intern!(static ID_SYM_EVALUATION_PATH_POINTER: "evaluation_path_pointer"); + +struct BuiltValidator { + validator: jsonschema::Validator, + callback_roots: CallbackRoots, + compilation_roots: CompilationRootsRef, +} + +fn build_validator( + ruby: &Ruby, + options: ValidationOptions, + retriever: Option, + callback_roots: CallbackRoots, + compilation_roots: Arc, + schema: &serde_json::Value, +) -> Result { + let validator = match retriever { + Some(ret) => options.with_retriever(ret).build(schema), + None => options.build(schema), + } + .map_err(|error| { + if let jsonschema::error::ValidationErrorKind::Referencing(err) = error.kind() { + if let Some(message) = retriever_error_message(err) { + Error::new(ruby.exception_arg_error(), message) + } else { + referencing_error(ruby, err.to_string()) + } + } else { + Error::new(ruby.exception_arg_error(), error.to_string()) + } + })?; + + Ok(BuiltValidator { + validator, + callback_roots, + compilation_roots, + }) +} + +/// RAII guard that registers Ruby callback values as GC roots for the duration +/// of a one-off validation call (module-level `valid?`, `validate!`, etc.). +/// +/// Persistent `Validator` instances protect their callbacks via the GC mark phase +/// (see `Validator::mark_callback_roots`). One-off calls have no such wrapper, so +/// this guard calls `register_address` on construction and `unregister_address` on +/// drop to keep callbacks alive while validation runs. +struct CallbackRootGuard { + roots: Vec, +} + +impl CallbackRootGuard { + fn new(ruby: &Ruby, callback_roots: &CallbackRoots) -> Self { + let roots = { + let roots_guard = match callback_roots.lock() { + Ok(roots) => roots, + Err(poisoned) => poisoned.into_inner(), + }; + roots_guard + .iter() + .map(|root| ruby.get_inner(*root)) + .collect::>() + }; + // We do not mutate `roots` after this point, so references used for GC address + // registration remain valid for the lifetime of the guard. + for root in &roots { + register_address(root); + } + + Self { roots } + } +} + +impl Drop for CallbackRootGuard { + fn drop(&mut self) { + for root in &self.roots { + unregister_address(root); + } + } +} + +fn build_parsed_options( + ruby: &Ruby, + kw: ExtractedKwargs, + draft_override: Option, +) -> Result { + let ( + draft_val, + validate_formats, + ignore_unknown_formats, + mask, + base_uri, + retriever, + formats, + keywords, + registry, + ) = kw.base; + let parsed_draft = match draft_val { + Some(val) => Some(parse_draft_symbol(ruby, val)?), + None => None, + }; + make_options_from_kwargs( + ruby, + draft_override.or(parsed_draft), + validate_formats, + ignore_unknown_formats, + mask, + base_uri, + retriever, + formats, + keywords, + registry, + kw.pattern_options, + kw.email_options, + kw.http_options, + ) +} + +thread_local! { + static LAST_CALLBACK_ERROR: RefCell> = const { RefCell::new(None) }; + /// When `true`, the custom panic hook suppresses output (inside `catch_unwind` blocks). + static SUPPRESS_PANIC_OUTPUT: RefCell = const { RefCell::new(false) }; +} + +static VALIDATION_ERROR_CLASS: Lazy = Lazy::new(|ruby| { + let module: RModule = ruby + .class_object() + .const_get("JSONSchema") + .expect("JSONSchema module must be defined before native extension is used"); + let cls: RClass = module + .const_get("ValidationError") + .expect("JSONSchema::ValidationError must be defined before native extension is used"); + let exc_cls = ExceptionClass::from_value(cls.as_value()) + .expect("JSONSchema::ValidationError must be an exception class"); + register_mark_object(exc_cls); + exc_cls +}); + +static REFERENCING_ERROR_CLASS: Lazy = Lazy::new(|ruby| { + let module: RModule = ruby + .class_object() + .const_get("JSONSchema") + .expect("JSONSchema module must be defined before native extension is used"); + let cls: RClass = module + .const_get("ReferencingError") + .expect("JSONSchema::ReferencingError must be defined before native extension is used"); + let exc_cls = ExceptionClass::from_value(cls.as_value()) + .expect("JSONSchema::ReferencingError must be an exception class"); + register_mark_object(exc_cls); + exc_cls +}); + +struct StringWriter<'a>(&'a mut String); + +#[allow(unsafe_code)] +impl std::io::Write for StringWriter<'_> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // SAFETY: `serde_json` always produces valid UTF-8 + self.0 + .push_str(unsafe { std::str::from_utf8_unchecked(buf) }); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Build a verbose error message with schema path, instance path, and instance value. +fn build_verbose_message( + mut message: String, + schema_path: &jsonschema::paths::Location, + instance_path: &jsonschema::paths::Location, + root_instance: Option<&serde_json::Value>, + failing_instance: &serde_json::Value, + mask: Option<&str>, +) -> String { + let schema_path_str = schema_path.as_str(); + let instance_path_str = instance_path.as_str(); + + let estimated_addition = + 150 + schema_path_str.len() + instance_path_str.len() + mask.map_or(100, str::len); // Mask length or ~100 for JSON serialization + + message.reserve(estimated_addition); + message.push_str("\n\nFailed validating"); + + let is_index_segment = + |segment: &str| segment.bytes().all(|b| b.is_ascii_digit()) && !segment.is_empty(); + let is_schema_property_map = |segment: Option<&str>| { + matches!( + segment, + Some( + "properties" + | "patternProperties" + | "dependentSchemas" + | "$defs" + | "definitions" + | "dependencies", + ) + ) + }; + let push_segment = |m: &mut String, segment: &str, is_index: bool| { + if is_index { + m.push_str(segment); + } else { + m.push('"'); + m.push_str(segment); + m.push('"'); + } + }; + + let mut schema_segments = Vec::new(); + let mut previous_schema_segment: Option = None; + for segment in schema_path_str.split('/').skip(1) { + let segment = unescape_segment(segment); + let segment = segment.as_ref(); + let is_index = is_index_segment(segment) + && !is_schema_property_map(previous_schema_segment.as_deref()); + schema_segments.push((segment.to_owned(), is_index)); + previous_schema_segment = Some(segment.to_owned()); + } + + if let Some((last, rest)) = schema_segments.split_last() { + message.push(' '); + push_segment(&mut message, &last.0, last.1); + message.push_str(" in schema"); + for (segment, is_index) in rest { + message.push('['); + push_segment(&mut message, segment, *is_index); + message.push(']'); + } + } else { + message.push_str(" in schema"); + } + + message.push_str("\n\nOn instance"); + let mut current = root_instance; + for segment in instance_path_str.split('/').skip(1) { + let segment = unescape_segment(segment); + let segment = segment.as_ref(); + let is_index = match current { + Some(serde_json::Value::Object(_)) => false, + _ => is_index_segment(segment), + }; + message.push('['); + push_segment(&mut message, segment, is_index); + message.push(']'); + + current = match (current, is_index) { + (Some(serde_json::Value::Array(values)), true) => segment + .parse::() + .ok() + .and_then(|idx| values.get(idx)), + (Some(serde_json::Value::Object(values)), false) => values.get(segment), + _ => None, + }; + } + message.push_str(":\n "); + + if let Some(mask) = mask { + message.push_str(mask); + } else { + let mut writer = StringWriter(&mut message); + serde_json::to_writer(&mut writer, failing_instance).expect("Failed to serialize JSON"); + } + + message +} + +/// Compute the display message for a validation error, respecting the mask option. +fn error_message(error: &jsonschema::ValidationError<'_>, mask: Option<&str>) -> String { + if let Some(mask) = mask { + error.masked_with(mask).to_string() + } else { + error.to_string() + } +} + +/// Convert a jsonschema `ValidationError` to a Ruby `ValidationError`. +fn into_ruby_error( + ruby: &Ruby, + error: jsonschema::ValidationError<'_>, + root_instance: Option<&serde_json::Value>, + message: &str, + mask: Option<&str>, +) -> Result { + let rb_message = ruby.into_value(message); + let verbose_message = build_verbose_message( + message.to_owned(), + error.schema_path(), + error.instance_path(), + root_instance, + error.instance(), + mask, + ); + + let (instance, kind, instance_path, schema_path, evaluation_path) = error.into_parts(); + + let instance_path_ptr = ruby.into_value(instance_path.as_str()); + let schema_path_ptr = ruby.into_value(schema_path.as_str()); + let evaluation_path_ptr = ruby.into_value(evaluation_path.as_str()); + + let into_path_segment = |segment: LocationSegment<'_>| -> Value { + match segment { + LocationSegment::Property(property) => ruby.into_value(property.as_ref()), + LocationSegment::Index(idx) => ruby.into_value(idx), + } + }; + + let kind_obj = ValidationErrorKind::new(ruby, &kind, mask)?; + let rb_instance = ser::value_to_ruby(ruby, instance.as_ref())?; + + let exc_class = ruby.get_inner(&VALIDATION_ERROR_CLASS); + + let exc: RObject = exc_class.funcall(*ID_ALLOCATE, ())?; + + exc.ivar_set(*ID_AT_MESSAGE, rb_message)?; + exc.ivar_set( + *ID_AT_VERBOSE_MESSAGE, + ruby.into_value(verbose_message.as_str()), + )?; + exc.ivar_set( + *ID_AT_INSTANCE_PATH, + ruby.ary_from_iter(instance_path.into_iter().map(&into_path_segment)), + )?; + exc.ivar_set( + *ID_AT_SCHEMA_PATH, + ruby.ary_from_iter(schema_path.into_iter().map(&into_path_segment)), + )?; + exc.ivar_set( + *ID_AT_EVALUATION_PATH, + ruby.ary_from_iter(evaluation_path.into_iter().map(&into_path_segment)), + )?; + exc.ivar_set(*ID_AT_INSTANCE_PATH_POINTER, instance_path_ptr)?; + exc.ivar_set(*ID_AT_SCHEMA_PATH_POINTER, schema_path_ptr)?; + exc.ivar_set(*ID_AT_EVALUATION_PATH_POINTER, evaluation_path_ptr)?; + exc.ivar_set(*ID_AT_KIND, ruby.into_value(kind_obj))?; + exc.ivar_set(*ID_AT_INSTANCE, rb_instance)?; + + Ok(exc.as_value()) +} + +/// Convert a jsonschema `ValidationError` into a Ruby `ValidationError` value. +fn to_ruby_error_value( + ruby: &Ruby, + error: jsonschema::ValidationError<'_>, + root_instance: Option<&serde_json::Value>, + mask: Option<&str>, +) -> Result { + let message = error_message(&error, mask); + into_ruby_error(ruby, error, root_instance, &message, mask) +} + +fn referencing_error(ruby: &Ruby, message: String) -> Error { + let exc_class = ruby.get_inner(&REFERENCING_ERROR_CLASS); + Error::new(exc_class, message) +} + +fn raise_validation_error( + ruby: &Ruby, + error: jsonschema::ValidationError<'_>, + root_instance: Option<&serde_json::Value>, + mask: Option<&str>, +) -> Error { + let message = error_message(&error, mask); + match into_ruby_error(ruby, error, root_instance, &message, mask) { + Ok(exc_value) => { + if let Some(exc) = Exception::from_value(exc_value) { + exc.into() + } else { + let exc_class = ruby.get_inner(&VALIDATION_ERROR_CLASS); + Error::new(exc_class, message) + } + } + Err(e) => e, + } +} + +/// RAII guard that sets `SUPPRESS_PANIC_OUTPUT` to `true` on creation and +/// resets it to `false` on drop, ensuring the flag is always restored even +/// if `catch_unwind` itself encounters a double-panic. +struct SuppressPanicGuard; + +impl SuppressPanicGuard { + fn new() -> Self { + SUPPRESS_PANIC_OUTPUT.with(|flag| *flag.borrow_mut() = true); + SuppressPanicGuard + } +} + +impl Drop for SuppressPanicGuard { + fn drop(&mut self) { + SUPPRESS_PANIC_OUTPUT.with(|flag| *flag.borrow_mut() = false); + } +} + +/// Run a closure with panic output suppressed, catching panics. +fn catch_unwind_silent(f: F) -> Result> +where + F: FnOnce() -> R + panic::UnwindSafe, +{ + let _guard = SuppressPanicGuard::new(); + panic::catch_unwind(f) +} + +#[allow(clippy::needless_pass_by_value)] +fn handle_callback_panic(ruby: &Ruby, err: Box) -> Error { + LAST_CALLBACK_ERROR.with(|last| { + if let Some(err) = last.borrow_mut().take() { + err + } else { + let msg = if let Some(s) = err.downcast_ref::<&str>() { + format!("Validation callback panicked: {s}") + } else if let Some(s) = err.downcast_ref::() { + format!("Validation callback panicked: {s}") + } else { + "Validation callback panicked".to_string() + }; + Error::new(ruby.exception_runtime_error(), msg) + } + }) +} + +#[allow(clippy::needless_pass_by_value)] +fn handle_without_gvl_panic(ruby: &Ruby, err: Box) -> Error { + let msg = if let Some(s) = err.downcast_ref::<&str>() { + format!("Validation panicked: {s}") + } else if let Some(s) = err.downcast_ref::() { + format!("Validation panicked: {s}") + } else { + "Validation panicked".to_string() + }; + Error::new(ruby.exception_runtime_error(), msg) +} + +/// Run a closure without holding the Ruby GVL. +/// +/// The closure runs on the same thread, just without the GVL held, +/// allowing other Ruby threads to proceed. The closure MUST NOT +/// access any Ruby objects or call any Ruby API. +/// +/// # Safety +/// Caller must ensure the closure does not interact with Ruby. +#[allow(unsafe_code)] +unsafe fn without_gvl(f: F) -> Result> +where + F: FnMut() -> R, +{ + struct Payload { + f: F, + result: std::mem::MaybeUninit>>, + } + + unsafe extern "C" fn call R, R>( + data: *mut std::ffi::c_void, + ) -> *mut std::ffi::c_void { + let payload = unsafe { &mut *data.cast::>() }; + let result = panic::catch_unwind(AssertUnwindSafe(|| (payload.f)())); + payload.result.write(result); + std::ptr::null_mut() + } + + let mut payload = Payload { + f, + result: std::mem::MaybeUninit::uninit(), + }; + + unsafe { + rb_sys::rb_thread_call_without_gvl( + Some(call::), + (&raw mut payload).cast::(), + None, + std::ptr::null_mut(), + ) + }; + + unsafe { payload.result.assume_init() } +} + +/// Wrapper around `jsonschema::Validator`. +/// +/// Holds GC-protection state for Ruby callbacks (format checkers, custom keywords, +/// retrievers) that live inside the inner `jsonschema::Validator` as trait objects. +/// See the doc comment on `CallbackRoots` for the full picture. +#[derive(magnus::TypedData)] +#[magnus(class = "JSONSchema::Validator", free_immediately, size, mark)] +pub struct Validator { + validator: jsonschema::Validator, + mask: Option, + has_ruby_callbacks: bool, + /// Marked during Ruby's GC mark phase to keep runtime callbacks alive. + callback_roots: CallbackRoots, + /// Protects callbacks via `register_address` during schema compilation โ€” + /// before this wrapper exists and `mark()` can run. Held so that its `Drop` + /// impl calls `unregister_address` to balance the registrations. + _compilation_roots: CompilationRootsRef, +} + +impl DataTypeFunctions for Validator { + fn mark(&self, marker: &magnus::gc::Marker) { + self.mark_callback_roots(marker); + } +} + +impl Validator { + fn mark_callback_roots(&self, marker: &magnus::gc::Marker) { + // Avoid panicking in Ruby GC mark paths; preserving existing roots is safer than aborting. + let roots = match self.callback_roots.lock() { + Ok(roots) => roots, + Err(poisoned) => poisoned.into_inner(), + }; + for root in roots.iter().copied() { + marker.mark(root); + } + } + + #[allow(unsafe_code)] + fn is_valid(ruby: &Ruby, rb_self: &Self, instance: Value) -> Result { + let json_instance = to_value(ruby, instance)?; + + if rb_self.has_ruby_callbacks { + let result = catch_unwind_silent(AssertUnwindSafe(|| { + rb_self.validator.is_valid(&json_instance) + })); + match result { + Ok(valid) => Ok(valid), + Err(err) => Err(handle_callback_panic(ruby, err)), + } + } else { + // SAFETY: validation is pure Rust with no Ruby callbacks + match unsafe { without_gvl(|| rb_self.validator.is_valid(&json_instance)) } { + Ok(valid) => Ok(valid), + Err(err) => Err(handle_without_gvl_panic(ruby, err)), + } + } + } + + #[allow(unsafe_code)] + fn validate(ruby: &Ruby, rb_self: &Self, instance: Value) -> Result<(), Error> { + let json_instance = to_value(ruby, instance)?; + + if rb_self.has_ruby_callbacks { + let result = catch_unwind_silent(AssertUnwindSafe(|| { + rb_self.validator.validate(&json_instance) + })); + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(raise_validation_error( + ruby, + error, + Some(&json_instance), + rb_self.mask.as_deref(), + )), + Err(err) => Err(handle_callback_panic(ruby, err)), + } + } else { + // SAFETY: validation is pure Rust with no Ruby callbacks + match unsafe { without_gvl(|| rb_self.validator.validate(&json_instance)) } { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(raise_validation_error( + ruby, + error, + Some(&json_instance), + rb_self.mask.as_deref(), + )), + Err(err) => Err(handle_without_gvl_panic(ruby, err)), + } + } + } + + #[allow(unsafe_code)] + fn iter_errors(ruby: &Ruby, rb_self: &Self, instance: Value) -> Result { + let json_instance = to_value(ruby, instance)?; + + if ruby.block_given() { + // Lazy path: yield errors one at a time to the block + if rb_self.has_ruby_callbacks { + let mut iter = rb_self.validator.iter_errors(&json_instance); + loop { + let result = catch_unwind_silent(AssertUnwindSafe(|| iter.next())); + match result { + Ok(Some(error)) => { + let ruby_error = to_ruby_error_value( + ruby, + error, + Some(&json_instance), + rb_self.mask.as_deref(), + )?; + ruby.yield_value::(ruby_error)?; + } + Ok(None) => break, + Err(err) => return Err(handle_callback_panic(ruby, err)), + } + } + } else { + for error in rb_self.validator.iter_errors(&json_instance) { + let ruby_error = to_ruby_error_value( + ruby, + error, + Some(&json_instance), + rb_self.mask.as_deref(), + )?; + ruby.yield_value::(ruby_error)?; + } + } + Ok(ruby.qnil().as_value()) + } else if rb_self.has_ruby_callbacks { + // Eager path with callbacks + let result = catch_unwind_silent(AssertUnwindSafe(|| { + rb_self + .validator + .iter_errors(&json_instance) + .collect::>() + })); + match result { + Ok(errors) => { + let arr = ruby.ary_new_capa(errors.len()); + for e in errors { + arr.push(to_ruby_error_value( + ruby, + e, + Some(&json_instance), + rb_self.mask.as_deref(), + )?)?; + } + Ok(arr.as_value()) + } + Err(err) => Err(handle_callback_panic(ruby, err)), + } + } else { + // Eager path without callbacks โ€” release GVL + // SAFETY: validation is pure Rust with no Ruby callbacks + let errors = match unsafe { + without_gvl(|| { + rb_self + .validator + .iter_errors(&json_instance) + .collect::>() + }) + } { + Ok(errors) => errors, + Err(err) => return Err(handle_without_gvl_panic(ruby, err)), + }; + let arr = ruby.ary_new_capa(errors.len()); + for e in errors { + arr.push(to_ruby_error_value( + ruby, + e, + Some(&json_instance), + rb_self.mask.as_deref(), + )?)?; + } + Ok(arr.as_value()) + } + } + + #[allow(unsafe_code)] + fn evaluate(ruby: &Ruby, rb_self: &Self, instance: Value) -> Result { + let json_instance = to_value(ruby, instance)?; + + if rb_self.has_ruby_callbacks { + let result = catch_unwind_silent(AssertUnwindSafe(|| { + rb_self.validator.evaluate(&json_instance) + })); + match result { + Ok(output) => Ok(Evaluation::new(output)), + Err(err) => Err(handle_callback_panic(ruby, err)), + } + } else { + // SAFETY: validation is pure Rust with no Ruby callbacks + let output = match unsafe { without_gvl(|| rb_self.validator.evaluate(&json_instance)) } + { + Ok(output) => output, + Err(err) => return Err(handle_without_gvl_panic(ruby, err)), + }; + Ok(Evaluation::new(output)) + } + } + + fn inspect(&self) -> String { + let draft = match self.validator.draft() { + jsonschema::Draft::Draft4 => "Draft4", + jsonschema::Draft::Draft6 => "Draft6", + jsonschema::Draft::Draft7 => "Draft7", + jsonschema::Draft::Draft201909 => "Draft201909", + jsonschema::Draft::Draft202012 => "Draft202012", + _ => "Unknown", + }; + format!("#") + } +} + +fn validator_for(ruby: &Ruby, args: &[Value]) -> Result { + let parsed_args = scan_args::<(Value,), (), (), (), _, ()>(args)?; + let (schema,) = parsed_args.required; + let kw = extract_kwargs_no_draft(ruby, parsed_args.keywords)?; + + let json_schema = to_schema_value(ruby, schema)?; + let parsed = build_parsed_options(ruby, kw, None)?; + let has_ruby_callbacks = parsed.has_ruby_callbacks; + let BuiltValidator { + validator, + callback_roots, + compilation_roots, + } = build_validator( + ruby, + parsed.options, + parsed.retriever, + parsed.callback_roots, + parsed.compilation_roots, + &json_schema, + )?; + Ok(Validator { + validator, + mask: parsed.mask, + has_ruby_callbacks, + callback_roots, + _compilation_roots: compilation_roots, + }) +} + +#[allow(unsafe_code)] +fn is_valid(ruby: &Ruby, args: &[Value]) -> Result { + let parsed_args = scan_args::<(Value, Value), (), (), (), _, ()>(args)?; + let (schema, instance) = parsed_args.required; + let kw = extract_kwargs(ruby, parsed_args.keywords)?; + + let json_schema = to_schema_value(ruby, schema)?; + let json_instance = to_value(ruby, instance)?; + let parsed = build_parsed_options(ruby, kw, None)?; + let has_ruby_callbacks = parsed.has_ruby_callbacks; + let BuiltValidator { + validator, + callback_roots, + compilation_roots: _compilation_roots, + } = build_validator( + ruby, + parsed.options, + parsed.retriever, + parsed.callback_roots, + parsed.compilation_roots, + &json_schema, + )?; + + if has_ruby_callbacks { + let _callback_roots = CallbackRootGuard::new(ruby, &callback_roots); + let result = catch_unwind_silent(AssertUnwindSafe(|| validator.is_valid(&json_instance))); + match result { + Ok(valid) => Ok(valid), + Err(err) => Err(handle_callback_panic(ruby, err)), + } + } else { + // SAFETY: validation is pure Rust with no Ruby callbacks + match unsafe { without_gvl(|| validator.is_valid(&json_instance)) } { + Ok(valid) => Ok(valid), + Err(err) => Err(handle_without_gvl_panic(ruby, err)), + } + } +} + +#[allow(unsafe_code)] +fn validate(ruby: &Ruby, args: &[Value]) -> Result<(), Error> { + let parsed_args = scan_args::<(Value, Value), (), (), (), _, ()>(args)?; + let (schema, instance) = parsed_args.required; + let kw = extract_kwargs(ruby, parsed_args.keywords)?; + + let json_schema = to_schema_value(ruby, schema)?; + let json_instance = to_value(ruby, instance)?; + let parsed = build_parsed_options(ruby, kw, None)?; + let has_ruby_callbacks = parsed.has_ruby_callbacks; + let BuiltValidator { + validator, + callback_roots, + compilation_roots: _compilation_roots, + } = build_validator( + ruby, + parsed.options, + parsed.retriever, + parsed.callback_roots, + parsed.compilation_roots, + &json_schema, + )?; + + if has_ruby_callbacks { + let _callback_roots = CallbackRootGuard::new(ruby, &callback_roots); + let result = catch_unwind_silent(AssertUnwindSafe(|| validator.validate(&json_instance))); + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(raise_validation_error( + ruby, + error, + Some(&json_instance), + parsed.mask.as_deref(), + )), + Err(err) => Err(handle_callback_panic(ruby, err)), + } + } else { + // SAFETY: validation is pure Rust with no Ruby callbacks + match unsafe { without_gvl(|| validator.validate(&json_instance)) } { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(raise_validation_error( + ruby, + error, + Some(&json_instance), + parsed.mask.as_deref(), + )), + Err(err) => Err(handle_without_gvl_panic(ruby, err)), + } + } +} + +#[allow(unsafe_code)] +fn each_error(ruby: &Ruby, args: &[Value]) -> Result { + let parsed_args = scan_args::<(Value, Value), (), (), (), _, ()>(args)?; + let (schema, instance) = parsed_args.required; + let kw = extract_kwargs(ruby, parsed_args.keywords)?; + + let json_schema = to_schema_value(ruby, schema)?; + let json_instance = to_value(ruby, instance)?; + let parsed = build_parsed_options(ruby, kw, None)?; + let has_ruby_callbacks = parsed.has_ruby_callbacks; + let BuiltValidator { + validator, + callback_roots, + compilation_roots: _compilation_roots, + } = build_validator( + ruby, + parsed.options, + parsed.retriever, + parsed.callback_roots, + parsed.compilation_roots, + &json_schema, + )?; + + if ruby.block_given() { + // Lazy path: yield errors one at a time to the block + if has_ruby_callbacks { + let _callback_roots = CallbackRootGuard::new(ruby, &callback_roots); + let mut iter = validator.iter_errors(&json_instance); + loop { + let result = catch_unwind_silent(AssertUnwindSafe(|| iter.next())); + match result { + Ok(Some(error)) => { + let ruby_error = to_ruby_error_value( + ruby, + error, + Some(&json_instance), + parsed.mask.as_deref(), + )?; + ruby.yield_value::(ruby_error)?; + } + Ok(None) => break, + Err(err) => return Err(handle_callback_panic(ruby, err)), + } + } + } else { + for error in validator.iter_errors(&json_instance) { + let ruby_error = + to_ruby_error_value(ruby, error, Some(&json_instance), parsed.mask.as_deref())?; + ruby.yield_value::(ruby_error)?; + } + } + Ok(ruby.qnil().as_value()) + } else if has_ruby_callbacks { + // Eager path with callbacks + let _callback_roots = CallbackRootGuard::new(ruby, &callback_roots); + let result = catch_unwind_silent(AssertUnwindSafe(|| { + validator.iter_errors(&json_instance).collect::>() + })); + match result { + Ok(errors) => { + let arr = ruby.ary_new_capa(errors.len()); + for e in errors { + arr.push(to_ruby_error_value( + ruby, + e, + Some(&json_instance), + parsed.mask.as_deref(), + )?)?; + } + Ok(arr.as_value()) + } + Err(err) => Err(handle_callback_panic(ruby, err)), + } + } else { + // Eager path without callbacks โ€” release GVL + // SAFETY: validation is pure Rust with no Ruby callbacks + let errors = match unsafe { + without_gvl(|| validator.iter_errors(&json_instance).collect::>()) + } { + Ok(errors) => errors, + Err(err) => return Err(handle_without_gvl_panic(ruby, err)), + }; + let arr = ruby.ary_new_capa(errors.len()); + for e in errors { + arr.push(to_ruby_error_value( + ruby, + e, + Some(&json_instance), + parsed.mask.as_deref(), + )?)?; + } + Ok(arr.as_value()) + } +} + +#[allow(unsafe_code)] +fn evaluate(ruby: &Ruby, args: &[Value]) -> Result { + let parsed_args = scan_args::<(Value, Value), (), (), (), _, ()>(args)?; + let (schema, instance) = parsed_args.required; + let kw = extract_evaluate_kwargs(ruby, parsed_args.keywords)?; + + let json_schema = to_schema_value(ruby, schema)?; + let json_instance = to_value(ruby, instance)?; + let parsed = build_parsed_options(ruby, kw, None)?; + let has_ruby_callbacks = parsed.has_ruby_callbacks; + let BuiltValidator { + validator, + callback_roots, + compilation_roots: _compilation_roots, + } = build_validator( + ruby, + parsed.options, + parsed.retriever, + parsed.callback_roots, + parsed.compilation_roots, + &json_schema, + )?; + + if has_ruby_callbacks { + let _callback_roots = CallbackRootGuard::new(ruby, &callback_roots); + let result = catch_unwind_silent(AssertUnwindSafe(|| validator.evaluate(&json_instance))); + match result { + Ok(output) => Ok(Evaluation::new(output)), + Err(err) => Err(handle_callback_panic(ruby, err)), + } + } else { + // SAFETY: validation is pure Rust with no Ruby callbacks + let output = match unsafe { without_gvl(|| validator.evaluate(&json_instance)) } { + Ok(output) => output, + Err(err) => return Err(handle_without_gvl_panic(ruby, err)), + }; + Ok(Evaluation::new(output)) + } +} + +macro_rules! define_draft_validator { + ($name:ident, $class_name:expr, $draft:expr) => { + #[derive(magnus::TypedData)] + #[magnus(class = $class_name, free_immediately, size, mark)] + pub struct $name { + inner: Validator, + } + + impl DataTypeFunctions for $name { + fn mark(&self, marker: &magnus::gc::Marker) { + self.inner.mark_callback_roots(marker); + } + } + + impl $name { + fn new_impl(ruby: &Ruby, args: &[Value]) -> Result { + let parsed_args = scan_args::<(Value,), (), (), (), _, ()>(args)?; + let (schema,) = parsed_args.required; + let kw = extract_kwargs_no_draft(ruby, parsed_args.keywords)?; + + let json_schema = to_schema_value(ruby, schema)?; + let parsed = build_parsed_options(ruby, kw, Some($draft))?; + let has_ruby_callbacks = parsed.has_ruby_callbacks; + let BuiltValidator { + validator, + callback_roots, + compilation_roots, + } = build_validator( + ruby, + parsed.options, + parsed.retriever, + parsed.callback_roots, + parsed.compilation_roots, + &json_schema, + )?; + Ok($name { + inner: Validator { + validator, + mask: parsed.mask, + has_ruby_callbacks, + callback_roots, + _compilation_roots: compilation_roots, + }, + }) + } + + fn is_valid(ruby: &Ruby, rb_self: &Self, instance: Value) -> Result { + Validator::is_valid(ruby, &rb_self.inner, instance) + } + + fn validate(ruby: &Ruby, rb_self: &Self, instance: Value) -> Result<(), Error> { + Validator::validate(ruby, &rb_self.inner, instance) + } + + fn iter_errors(ruby: &Ruby, rb_self: &Self, instance: Value) -> Result { + Validator::iter_errors(ruby, &rb_self.inner, instance) + } + + fn evaluate(ruby: &Ruby, rb_self: &Self, instance: Value) -> Result { + Validator::evaluate(ruby, &rb_self.inner, instance) + } + + fn inspect(&self) -> String { + self.inner.inspect() + } + } + }; +} + +define_draft_validator!( + Draft4Validator, + "JSONSchema::Draft4Validator", + jsonschema::Draft::Draft4 +); +define_draft_validator!( + Draft6Validator, + "JSONSchema::Draft6Validator", + jsonschema::Draft::Draft6 +); +define_draft_validator!( + Draft7Validator, + "JSONSchema::Draft7Validator", + jsonschema::Draft::Draft7 +); +define_draft_validator!( + Draft201909Validator, + "JSONSchema::Draft201909Validator", + jsonschema::Draft::Draft201909 +); +define_draft_validator!( + Draft202012Validator, + "JSONSchema::Draft202012Validator", + jsonschema::Draft::Draft202012 +); + +fn meta_is_valid(ruby: &Ruby, args: &[Value]) -> Result { + use magnus::scan_args::get_kwargs; + let parsed_args = scan_args::<(Value,), (), (), (), _, ()>(args)?; + let (schema,) = parsed_args.required; + let kw: magnus::scan_args::KwArgs<(), (Option>,), ()> = + get_kwargs(parsed_args.keywords, &[], &[*options::KW_REGISTRY])?; + let registry = kw.optional.0.flatten(); + + let json_schema = to_schema_value(ruby, schema)?; + + let result = if let Some(reg) = registry { + jsonschema::meta::options() + .with_registry(reg.inner.clone()) + .validate(&json_schema) + } else { + jsonschema::meta::validate(&json_schema) + }; + + match result { + Ok(()) => Ok(true), + Err(error) => { + if let jsonschema::error::ValidationErrorKind::Referencing(err) = error.kind() { + return Err(referencing_error(ruby, err.to_string())); + } + Ok(false) + } + } +} + +fn meta_validate(ruby: &Ruby, args: &[Value]) -> Result<(), Error> { + use magnus::scan_args::get_kwargs; + let parsed_args = scan_args::<(Value,), (), (), (), _, ()>(args)?; + let (schema,) = parsed_args.required; + let kw: magnus::scan_args::KwArgs<(), (Option>,), ()> = + get_kwargs(parsed_args.keywords, &[], &[*options::KW_REGISTRY])?; + let registry = kw.optional.0.flatten(); + + let json_schema = to_schema_value(ruby, schema)?; + + let result = if let Some(reg) = registry { + jsonschema::meta::options() + .with_registry(reg.inner.clone()) + .validate(&json_schema) + } else { + jsonschema::meta::validate(&json_schema) + }; + + match result { + Ok(()) => Ok(()), + Err(error) => { + if let jsonschema::error::ValidationErrorKind::Referencing(err) = error.kind() { + return Err(referencing_error(ruby, err.to_string())); + } + Err(raise_validation_error( + ruby, + error, + Some(&json_schema), + None, + )) + } + } +} + +// ValidationError instance methods (defined from Rust, called on exception instances) + +fn validation_error_to_s(ruby: &Ruby, rb_self: Value) -> Result { + let obj = RObject::from_value(rb_self).ok_or_else(|| { + Error::new( + Ruby::get().expect("Ruby").exception_type_error(), + "expected object", + ) + })?; + let message: Value = obj.ivar_get(*ID_AT_MESSAGE)?; + if message.is_nil() { + ruby.call_super(()) + } else { + Ok(message) + } +} + +fn validation_error_inspect(_ruby: &Ruby, rb_self: Value) -> Result { + let msg: String = rb_self.funcall("to_s", ())?; + Ok(format!("#")) +} + +fn validation_error_eq(ruby: &Ruby, rb_self: Value, other: Value) -> Result { + let exc_class = ruby.get_inner(&VALIDATION_ERROR_CLASS); + let other_obj = match RObject::from_value(other) { + Some(obj) if obj.is_kind_of(exc_class) => obj, + _ => return Ok(false), + }; + let self_obj = RObject::from_value(rb_self) + .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected object"))?; + + let self_key = ruby.ary_new_capa(3); + self_key.push(self_obj.ivar_get::<_, Value>(*ID_AT_MESSAGE)?)?; + self_key.push(self_obj.ivar_get::<_, Value>(*ID_AT_SCHEMA_PATH)?)?; + self_key.push(self_obj.ivar_get::<_, Value>(*ID_AT_INSTANCE_PATH)?)?; + + let other_key = ruby.ary_new_capa(3); + other_key.push(other_obj.ivar_get::<_, Value>(*ID_AT_MESSAGE)?)?; + other_key.push(other_obj.ivar_get::<_, Value>(*ID_AT_SCHEMA_PATH)?)?; + other_key.push(other_obj.ivar_get::<_, Value>(*ID_AT_INSTANCE_PATH)?)?; + + self_key.funcall("==", (other_key,)) +} + +fn validation_error_hash(ruby: &Ruby, rb_self: Value) -> Result { + let obj = RObject::from_value(rb_self) + .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected object"))?; + let arr = ruby.ary_new_capa(3); + arr.push(obj.ivar_get::<_, Value>(*ID_AT_MESSAGE)?)?; + arr.push(obj.ivar_get::<_, Value>(*ID_AT_SCHEMA_PATH)?)?; + arr.push(obj.ivar_get::<_, Value>(*ID_AT_INSTANCE_PATH)?)?; + arr.funcall("hash", ()) +} + +#[magnus::init(name = "jsonschema_rb")] +fn init(ruby: &Ruby) -> Result<(), Error> { + // Conditionally suppress panic output โ€” only when inside `catch_unwind` + // blocks used for Ruby callback panics (format checkers, custom keywords). + // Other panics pass through to the default handler to preserve debugging output. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let suppress = SUPPRESS_PANIC_OUTPUT.with(|flag| *flag.borrow()); + if !suppress { + default_hook(info); + } + })); + + let module = ruby.define_module("JSONSchema")?; + + // ValidationError < StandardError + let validation_error_class = + module.define_error("ValidationError", ruby.exception_standard_error())?; + let validation_error_rclass = + RClass::from_value(validation_error_class.as_value()).expect("ExceptionClass is an RClass"); + for sym_id in [ + &*ID_SYM_MESSAGE, + &*ID_SYM_VERBOSE_MESSAGE, + &*ID_SYM_INSTANCE_PATH, + &*ID_SYM_SCHEMA_PATH, + &*ID_SYM_EVALUATION_PATH, + &*ID_SYM_KIND, + &*ID_SYM_INSTANCE, + &*ID_SYM_INSTANCE_PATH_POINTER, + &*ID_SYM_SCHEMA_PATH_POINTER, + &*ID_SYM_EVALUATION_PATH_POINTER, + ] { + let _: Value = validation_error_rclass.funcall("attr_reader", (sym_id.to_symbol(),))?; + } + validation_error_rclass.define_method("message", method!(validation_error_to_s, 0))?; + validation_error_rclass.define_method("to_s", method!(validation_error_to_s, 0))?; + validation_error_rclass.define_method("inspect", method!(validation_error_inspect, 0))?; + validation_error_rclass.define_method("==", method!(validation_error_eq, 1))?; + validation_error_rclass.define_alias("eql?", "==")?; + validation_error_rclass.define_method("hash", method!(validation_error_hash, 0))?; + + // ReferencingError < StandardError + module.define_error("ReferencingError", ruby.exception_standard_error())?; + + // Module-level functions + module.define_singleton_method("validator_for", function!(validator_for, -1))?; + module.define_singleton_method("valid?", function!(is_valid, -1))?; + module.define_singleton_method("validate!", function!(validate, -1))?; + module.define_singleton_method("each_error", function!(each_error, -1))?; + module.define_singleton_method("evaluate", function!(evaluate, -1))?; + + // Validator class + let validator_class = module.define_class("Validator", ruby.class_object())?; + validator_class.define_method("valid?", method!(Validator::is_valid, 1))?; + validator_class.define_method("validate!", method!(Validator::validate, 1))?; + validator_class.define_method("each_error", method!(Validator::iter_errors, 1))?; + validator_class.define_method("evaluate", method!(Validator::evaluate, 1))?; + validator_class.define_method("inspect", method!(Validator::inspect, 0))?; + + // Draft-specific validators + macro_rules! define_draft_class { + ($ruby:expr, $module:expr, $name:ident, $class_str:expr, $superclass:expr) => { + let cls = $module.define_class($class_str, $superclass)?; + cls.define_singleton_method("new", function!($name::new_impl, -1))?; + cls.define_method("valid?", method!($name::is_valid, 1))?; + cls.define_method("validate!", method!($name::validate, 1))?; + cls.define_method("each_error", method!($name::iter_errors, 1))?; + cls.define_method("evaluate", method!($name::evaluate, 1))?; + cls.define_method("inspect", method!($name::inspect, 0))?; + }; + } + + define_draft_class!( + ruby, + module, + Draft4Validator, + "Draft4Validator", + validator_class + ); + define_draft_class!( + ruby, + module, + Draft6Validator, + "Draft6Validator", + validator_class + ); + define_draft_class!( + ruby, + module, + Draft7Validator, + "Draft7Validator", + validator_class + ); + define_draft_class!( + ruby, + module, + Draft201909Validator, + "Draft201909Validator", + validator_class + ); + define_draft_class!( + ruby, + module, + Draft202012Validator, + "Draft202012Validator", + validator_class + ); + + // Internal implementation detail for shared validator behavior. + let _: Value = module.funcall("private_constant", ("Validator",))?; + + evaluation::define_class(ruby, &module)?; + registry::define_class(ruby, &module)?; + error_kind::define_class(ruby, &module)?; + options::define_classes(ruby, &module)?; + + let meta_module = module.define_module("Meta")?; + meta_module.define_singleton_method("valid?", function!(meta_is_valid, -1))?; + meta_module.define_singleton_method("validate!", function!(meta_validate, -1))?; + + Ok(()) +} diff --git a/crates/jsonschema-rb/src/options.rs b/crates/jsonschema-rb/src/options.rs new file mode 100644 index 000000000..26acda856 --- /dev/null +++ b/crates/jsonschema-rb/src/options.rs @@ -0,0 +1,1054 @@ +use std::{ + pin::Pin, + sync::{Arc, Mutex}, + time::Duration, +}; + +use magnus::{ + block::Proc, + function, + gc::{register_address, unregister_address}, + method, + prelude::*, + scan_args::{get_kwargs, scan_args, KwArgs}, + value::Opaque, + Error, RHash, RModule, Ruby, TryConvert, Value, +}; + +use crate::{ + registry::Registry, + retriever::{make_retriever, RubyRetriever}, + ser::{map_to_ruby, value_to_ruby}, + static_id::{define_rb_intern, StaticId}, + LAST_CALLBACK_ERROR, +}; + +// Base kwarg names +define_rb_intern!(static KW_DRAFT: "draft"); +define_rb_intern!(static KW_VALIDATE_FORMATS: "validate_formats"); +define_rb_intern!(static KW_IGNORE_UNKNOWN_FORMATS: "ignore_unknown_formats"); +define_rb_intern!(static KW_MASK: "mask"); +define_rb_intern!(static KW_BASE_URI: "base_uri"); +define_rb_intern!(static KW_RETRIEVER: "retriever"); +define_rb_intern!(static KW_FORMATS: "formats"); +define_rb_intern!(static KW_KEYWORDS: "keywords"); +define_rb_intern!(pub(crate) static KW_REGISTRY: "registry"); +// Extra kwarg names (extracted before get_kwargs) +define_rb_intern!(static KW_PATTERN_OPTIONS: "pattern_options"); +define_rb_intern!(static KW_EMAIL_OPTIONS: "email_options"); +define_rb_intern!(static KW_HTTP_OPTIONS: "http_options"); +// EmailOptions kwargs +define_rb_intern!(static KW_REQUIRE_TLD: "require_tld"); +define_rb_intern!(static KW_ALLOW_DOMAIN_LITERAL: "allow_domain_literal"); +define_rb_intern!(static KW_ALLOW_DISPLAY_TEXT: "allow_display_text"); +define_rb_intern!(static KW_MINIMUM_SUB_DOMAINS: "minimum_sub_domains"); +// RegexOptions / FancyRegexOptions kwargs +define_rb_intern!(static KW_SIZE_LIMIT: "size_limit"); +define_rb_intern!(static KW_DFA_SIZE_LIMIT: "dfa_size_limit"); +define_rb_intern!(static KW_BACKTRACK_LIMIT: "backtrack_limit"); +// HttpOptions kwargs +define_rb_intern!(static KW_TIMEOUT: "timeout"); +define_rb_intern!(static KW_CONNECT_TIMEOUT: "connect_timeout"); +define_rb_intern!(static KW_TLS_VERIFY: "tls_verify"); +define_rb_intern!(static KW_CA_CERT: "ca_cert"); +// Method symbols for respond_to? / method_defined? checks +define_rb_intern!(static SYM_CALL: "call"); +define_rb_intern!(static SYM_NEW: "new"); +define_rb_intern!(static SYM_VALIDATE: "validate"); + +pub struct ParsedOptions { + pub mask: Option, + pub options: jsonschema::ValidationOptions, + pub retriever: Option, + // Runtime callbacks invoked during `validator.*` calls (formats / custom keywords). + // Retriever callbacks are used at build time and do not affect GVL behavior at runtime. + pub has_ruby_callbacks: bool, + pub callback_roots: CallbackRoots, + pub compilation_roots: CompilationRootsRef, +} + +/// Ruby callbacks (format checkers, custom keyword instances, retrievers) are stored +/// inside the Rust `jsonschema::Validator` as trait objects. Ruby's GC cannot see +/// these references โ€” it only scans its own heap โ€” so without explicit protection +/// the GC would collect the callbacks while the validator still holds them, causing +/// use-after-free crashes. +/// +/// Two complementary collections keep every callback alive: +/// +/// * **`CallbackRoots`** โ€” held by the `Validator` wrapper and marked during Ruby's +/// GC mark phase (`DataTypeFunctions::mark`). This is the standard Magnus/Ruby +/// mechanism for preventing collection of objects referenced by native extensions. +/// For one-off validation functions (module-level `valid?`, `validate!`, etc.), +/// where no persistent `Validator` exists, a [`CallbackRootGuard`](crate::CallbackRootGuard) +/// temporarily registers the same roots via `register_address` for the duration +/// of the call. +/// +/// * **`CompilationRoots`** โ€” registered via `register_address` immediately when +/// added, so callbacks are protected during schema compilation (before the +/// `Validator` wrapper and its mark function exist). Unregistered on drop. +/// +/// Both collections hold the **same** Ruby objects; they differ only in *when* and +/// *how* they protect them from the GC. +pub type CallbackRoots = Arc>>>; +pub type CompilationRootsRef = Arc; + +#[derive(Default)] +pub struct CompilationRoots { + // Values are pinned so their addresses stay stable after GC registration. + roots: Mutex>>>>, +} + +impl CompilationRoots { + fn add(&self, value: Opaque) -> Result<(), ()> { + let mut roots = self.roots.lock().map_err(|_| ())?; + let pinned = Box::pin(value); + register_address(pinned.as_ref().get_ref()); + roots.push(pinned); + Ok(()) + } +} + +impl Drop for CompilationRoots { + fn drop(&mut self) { + let roots = match self.roots.get_mut() { + Ok(roots) => roots, + Err(poisoned) => poisoned.into_inner(), + }; + for root in roots.drain(..) { + unregister_address(root.as_ref().get_ref()); + } + } +} + +fn base_option_ids() -> [StaticId; 9] { + [ + *KW_DRAFT, + *KW_VALIDATE_FORMATS, + *KW_IGNORE_UNKNOWN_FORMATS, + *KW_MASK, + *KW_BASE_URI, + *KW_RETRIEVER, + *KW_FORMATS, + *KW_KEYWORDS, + *KW_REGISTRY, + ] +} +fn base_option_ids_no_mask() -> [StaticId; 8] { + [ + *KW_DRAFT, + *KW_VALIDATE_FORMATS, + *KW_IGNORE_UNKNOWN_FORMATS, + *KW_BASE_URI, + *KW_RETRIEVER, + *KW_FORMATS, + *KW_KEYWORDS, + *KW_REGISTRY, + ] +} + +type BaseKwargs = ( + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, +); +type BaseKwargsNoMask = ( + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, +); +type BaseKwargsNoDraft = ( + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, +); + +fn base_option_ids_no_draft() -> [StaticId; 8] { + [ + *KW_VALIDATE_FORMATS, + *KW_IGNORE_UNKNOWN_FORMATS, + *KW_MASK, + *KW_BASE_URI, + *KW_RETRIEVER, + *KW_FORMATS, + *KW_KEYWORDS, + *KW_REGISTRY, + ] +} + +pub fn parse_draft_symbol(ruby: &Ruby, val: Value) -> Result { + let sym: magnus::Symbol = TryConvert::try_convert(val).map_err(|_| { + Error::new( + ruby.exception_type_error(), + "draft must be a Symbol (e.g. :draft7)", + ) + })?; + let name = sym.name().map_err(|_| { + Error::new( + ruby.exception_arg_error(), + "Failed to read draft symbol name", + ) + })?; + match name.as_ref() { + "draft4" => Ok(jsonschema::Draft::Draft4), + "draft6" => Ok(jsonschema::Draft::Draft6), + "draft7" => Ok(jsonschema::Draft::Draft7), + "draft201909" => Ok(jsonschema::Draft::Draft201909), + "draft202012" => Ok(jsonschema::Draft::Draft202012), + _ => Err(Error::new( + ruby.exception_arg_error(), + format!( + "Unknown draft: :{name}. Valid drafts: :draft4, :draft6, :draft7, :draft201909, :draft202012" + ), + )), + } +} + +pub struct ExtractedKwargs { + pub base: BaseKwargs, + pub pattern_options: Option, + pub email_options: Option, + pub http_options: Option, +} + +pub fn extract_kwargs(_ruby: &Ruby, kw: RHash) -> Result { + let pattern_options = extract_and_delete(&kw, *KW_PATTERN_OPTIONS)?; + let email_options = extract_and_delete(&kw, *KW_EMAIL_OPTIONS)?; + let http_options = extract_and_delete(&kw, *KW_HTTP_OPTIONS)?; + + let ids = base_option_ids(); + let base_kw: KwArgs<(), BaseKwargs, ()> = get_kwargs(kw, &[], &ids)?; + + Ok(ExtractedKwargs { + base: base_kw.optional, + pattern_options, + email_options, + http_options, + }) +} + +pub fn extract_evaluate_kwargs(_ruby: &Ruby, kw: RHash) -> Result { + let pattern_options = extract_and_delete(&kw, *KW_PATTERN_OPTIONS)?; + let email_options = extract_and_delete(&kw, *KW_EMAIL_OPTIONS)?; + let http_options = extract_and_delete(&kw, *KW_HTTP_OPTIONS)?; + + let ids = base_option_ids_no_mask(); + let base_kw: KwArgs<(), BaseKwargsNoMask, ()> = get_kwargs(kw, &[], &ids)?; + let ( + draft, + validate_formats, + ignore_unknown_formats, + base_uri, + retriever, + formats, + keywords, + registry, + ) = base_kw.optional; + + Ok(ExtractedKwargs { + base: ( + draft, + validate_formats, + ignore_unknown_formats, + None, + base_uri, + retriever, + formats, + keywords, + registry, + ), + pattern_options, + email_options, + http_options, + }) +} + +pub fn extract_kwargs_no_draft(_ruby: &Ruby, kw: RHash) -> Result { + let pattern_options = extract_and_delete(&kw, *KW_PATTERN_OPTIONS)?; + let email_options = extract_and_delete(&kw, *KW_EMAIL_OPTIONS)?; + let http_options = extract_and_delete(&kw, *KW_HTTP_OPTIONS)?; + + let ids = base_option_ids_no_draft(); + let base_kw: KwArgs<(), BaseKwargsNoDraft, ()> = get_kwargs(kw, &[], &ids)?; + let ( + validate_formats, + ignore_unknown_formats, + mask, + base_uri, + retriever, + formats, + keywords, + registry, + ) = base_kw.optional; + + Ok(ExtractedKwargs { + base: ( + None, + validate_formats, + ignore_unknown_formats, + mask, + base_uri, + retriever, + formats, + keywords, + registry, + ), + pattern_options, + email_options, + http_options, + }) +} + +/// Extract a key from a Ruby Hash and remove it, returning None if not present or nil. +fn extract_and_delete(hash: &RHash, key: StaticId) -> Result, Error> { + let val: Option = hash.delete(key.to_symbol())?; + match val { + Some(v) if v.is_nil() => Ok(None), + other => Ok(other), + } +} + +fn timeout_duration(ruby: &Ruby, field: &str, value: f64) -> Result { + if !value.is_finite() || value < 0.0 { + return Err(Error::new( + ruby.exception_arg_error(), + format!("http_options.{field} must be a finite non-negative number"), + )); + } + Duration::try_from_secs_f64(value).map_err(|_| { + Error::new( + ruby.exception_arg_error(), + format!("http_options.{field} is too large"), + ) + }) +} + +/// Wrapper for a Ruby format checker proc that can be called from Rust. +struct RubyFormatChecker { + proc: Opaque, +} + +impl RubyFormatChecker { + fn check(&self, value: &str) -> bool { + let ruby = Ruby::get().expect("Ruby VM should be initialized"); + let proc = ruby.get_inner(self.proc); + let result: Result = proc.call((value,)); + match result { + Ok(v) => v, + Err(e) => { + LAST_CALLBACK_ERROR.with(|last| { + *last.borrow_mut() = Some(e); + }); + panic!("Format checker failed") + } + } + } +} + +/// Wrapper for a Ruby custom keyword validator factory. +struct RubyKeywordFactory { + class: Opaque, +} + +/// Wrapper for a Ruby custom keyword validator instance. +struct RubyKeyword { + instance: Opaque, +} + +impl jsonschema::Keyword for RubyKeyword { + fn validate<'i>( + &self, + instance: &'i serde_json::Value, + ) -> Result<(), jsonschema::ValidationError<'i>> { + let ruby = Ruby::get().expect("Ruby VM should be initialized"); + let rb_instance = value_to_ruby(&ruby, instance).map_err(|e| { + jsonschema::ValidationError::custom(format!("Failed to convert instance to Ruby: {e}")) + })?; + + let keyword = ruby.get_inner(self.instance); + let result: Result = keyword.funcall("validate", (rb_instance,)); + match result { + Ok(_) => Ok(()), + Err(e) => Err(jsonschema::ValidationError::custom(e.to_string())), + } + } + + fn is_valid(&self, instance: &serde_json::Value) -> bool { + let ruby = Ruby::get().expect("Ruby VM should be initialized"); + let Ok(rb_instance) = value_to_ruby(&ruby, instance) else { + return false; + }; + let inst = ruby.get_inner(self.instance); + let result: Result = inst.funcall("validate", (rb_instance,)); + result.is_ok() + } +} + +#[allow(clippy::too_many_arguments)] +pub fn make_options_from_kwargs( + ruby: &Ruby, + draft: Option, + validate_formats: Option, + ignore_unknown_formats: Option, + mask: Option, + base_uri: Option, + retriever_val: Option, + formats: Option, + keywords: Option, + registry_val: Option, + pattern_options_val: Option, + email_options_val: Option, + http_options_val: Option, +) -> Result { + let mut opts = jsonschema::options(); + let mut retriever = None; + let retriever_was_provided = retriever_val.is_some(); + let mut has_ruby_callbacks = false; + let callback_roots = Arc::new(Mutex::new(Vec::new())); + let compilation_roots = Arc::new(CompilationRoots::default()); + + if let Some(draft) = draft { + opts = opts.with_draft(draft); + } + + if let Some(validate) = validate_formats { + opts = opts.should_validate_formats(validate); + } + + if let Some(ignore) = ignore_unknown_formats { + opts = opts.should_ignore_unknown_formats(ignore); + } + + if let Some(uri) = base_uri { + opts = opts.with_base_uri(uri); + } + + if let Some(val) = retriever_val { + if let Some(ret) = make_retriever(ruby, val)? { + compilation_roots.add(Opaque::from(val)).map_err(|()| { + Error::new( + ruby.exception_runtime_error(), + "Compilation callback root storage is poisoned", + ) + })?; + { + let mut roots = callback_roots.lock().map_err(|_| { + Error::new( + ruby.exception_runtime_error(), + "Callback root storage is poisoned", + ) + })?; + roots.push(Opaque::from(val)); + } + retriever = Some(ret); + } + } + + if let Some(val) = registry_val { + if !val.is_nil() { + let reg: &Registry = TryConvert::try_convert(val).map_err(|_| { + Error::new( + ruby.exception_type_error(), + "registry must be a JSONSchema::Registry instance", + ) + })?; + opts = opts.with_registry(reg.inner.clone()); + + if !retriever_was_provided && retriever.is_none() { + if let Some(registry_retriever_value) = reg.retriever_value(ruby) { + if let Some(ret) = make_retriever(ruby, registry_retriever_value)? { + compilation_roots + .add(Opaque::from(registry_retriever_value)) + .map_err(|()| { + Error::new( + ruby.exception_runtime_error(), + "Compilation callback root storage is poisoned", + ) + })?; + { + let mut roots = callback_roots.lock().map_err(|_| { + Error::new( + ruby.exception_runtime_error(), + "Callback root storage is poisoned", + ) + })?; + roots.push(Opaque::from(registry_retriever_value)); + } + retriever = Some(ret); + } + } + } + } + } + + if let Some(formats_hash) = formats { + for item in formats_hash.enumeratorize("each", ()) { + has_ruby_callbacks = true; + let pair: magnus::RArray = magnus::TryConvert::try_convert(item?)?; + let name: String = pair.entry(0)?; + let callback: Value = pair.entry(1)?; + + let responds_to_call: bool = + callback.funcall("respond_to?", (SYM_CALL.to_symbol(),))?; + if !responds_to_call { + return Err(Error::new( + ruby.exception_type_error(), + format!("Format checker for '{name}' must be a callable (Proc or Lambda)"), + )); + } + + let proc = Proc::from_value(callback).ok_or_else(|| { + Error::new( + ruby.exception_type_error(), + format!("Failed to convert format checker '{name}' to Proc"), + ) + })?; + + compilation_roots + .add(Opaque::from(callback)) + .map_err(|()| { + Error::new( + ruby.exception_runtime_error(), + "Compilation callback root storage is poisoned", + ) + })?; + { + // During configuration, fail fast on poisoned state so we don't create + // validators with partially captured callback roots. + let mut roots = callback_roots.lock().map_err(|_| { + Error::new( + ruby.exception_runtime_error(), + "Callback root storage is poisoned", + ) + })?; + roots.push(Opaque::from(callback)); + } + + let checker = RubyFormatChecker { + proc: Opaque::from(proc), + }; + + opts = opts.with_format(name, move |value: &str| checker.check(value)); + } + } + + if let Some(keywords_hash) = keywords { + for item in keywords_hash.enumeratorize("each", ()) { + has_ruby_callbacks = true; + let pair: magnus::RArray = magnus::TryConvert::try_convert(item?)?; + let name: String = pair.entry(0)?; + let callback: Value = pair.entry(1)?; + + let responds_to_new: bool = callback.funcall("respond_to?", (SYM_NEW.to_symbol(),))?; + if !responds_to_new { + return Err(Error::new( + ruby.exception_type_error(), + format!( + "Keyword validator for '{name}' must be a class with 'new' and 'validate' methods" + ), + )); + } + + let has_validate: bool = + callback.funcall("method_defined?", (SYM_VALIDATE.to_symbol(),))?; + if !has_validate { + return Err(Error::new( + ruby.exception_type_error(), + format!( + "Keyword validator for '{name}' must define a 'validate' instance method" + ), + )); + } + + let callback_wrapper = Arc::new(RubyKeywordFactory { + class: Opaque::from(callback), + }); + compilation_roots + .add(Opaque::from(callback)) + .map_err(|()| { + Error::new( + ruby.exception_runtime_error(), + "Compilation callback root storage is poisoned", + ) + })?; + { + let mut roots = callback_roots.lock().map_err(|_| { + Error::new( + ruby.exception_runtime_error(), + "Callback root storage is poisoned", + ) + })?; + roots.push(Opaque::from(callback)); + } + let callback_roots_for_keyword = Arc::clone(&callback_roots); + let compilation_roots_for_keyword = Arc::clone(&compilation_roots); + let name_for_error = name.clone(); + + opts = opts.with_keyword( + name, + move |parent: &serde_json::Map, + value: &serde_json::Value, + path: jsonschema::paths::Location| { + let inner_ruby = Ruby::get().expect("Ruby VM should be initialized"); + let name_err = name_for_error.clone(); + let factory = callback_wrapper.clone(); + + // Convert parent schema map to Ruby hash directly + let rb_schema = map_to_ruby(&inner_ruby, parent).map_err(|e| { + jsonschema::ValidationError::custom(format!( + "Failed to convert schema to Ruby: {e}" + )) + })?; + + // Convert keyword value to Ruby + let rb_value = value_to_ruby(&inner_ruby, value).map_err(|e| { + jsonschema::ValidationError::custom(format!( + "Failed to convert keyword value to Ruby: {e}" + )) + })?; + + // Convert path to Ruby array + let rb_path = + inner_ruby.ary_from_iter(path.iter().map(|segment| match segment { + jsonschema::paths::LocationSegment::Property(p) => { + inner_ruby.into_value(p.as_ref()) + } + jsonschema::paths::LocationSegment::Index(i) => { + inner_ruby.into_value(i) + } + })); + + // Instantiate the keyword validator class with (parent_schema, value, path) + let class = inner_ruby.get_inner(factory.class); + let instance: Result = + class.funcall("new", (rb_schema, rb_value, rb_path)); + + match instance { + Ok(inst) => { + let opaque_inst = Opaque::from(inst); + compilation_roots_for_keyword + .add(opaque_inst) + .map_err(|()| { + jsonschema::ValidationError::custom( + "Compilation callback root storage is poisoned", + ) + })?; + let mut roots = callback_roots_for_keyword.lock().map_err(|_| { + jsonschema::ValidationError::custom( + "Callback root storage is poisoned", + ) + })?; + roots.push(opaque_inst); + Ok(Box::new(RubyKeyword { + instance: opaque_inst, + }) + as Box) + } + Err(e) => Err(jsonschema::ValidationError::custom(format!( + "Failed to instantiate keyword class '{name_err}': {e}" + ))), + } + }, + ); + } + } + + if let Some(val) = pattern_options_val { + if let Ok(fancy_opts) = <&FancyRegexOptions>::try_convert(val) { + let mut po = jsonschema::PatternOptions::fancy_regex(); + if let Some(limit) = fancy_opts.backtrack_limit { + po = po.backtrack_limit(limit); + } + if let Some(limit) = fancy_opts.size_limit { + po = po.size_limit(limit); + } + if let Some(limit) = fancy_opts.dfa_size_limit { + po = po.dfa_size_limit(limit); + } + opts = opts.with_pattern_options(po); + } else if let Ok(regex_opts) = <&RegexOptions>::try_convert(val) { + let mut po = jsonschema::PatternOptions::regex(); + if let Some(limit) = regex_opts.size_limit { + po = po.size_limit(limit); + } + if let Some(limit) = regex_opts.dfa_size_limit { + po = po.dfa_size_limit(limit); + } + opts = opts.with_pattern_options(po); + } else { + return Err(Error::new( + ruby.exception_type_error(), + "pattern_options must be a RegexOptions or FancyRegexOptions instance", + )); + } + } + + if let Some(val) = email_options_val { + let eopts: &EmailOptions = magnus::TryConvert::try_convert(val).map_err(|_| { + Error::new( + ruby.exception_type_error(), + "email_options must be an EmailOptions instance", + ) + })?; + let mut email_opts = jsonschema::EmailOptions::default(); + if eopts.require_tld { + email_opts = email_opts.with_required_tld(); + } + if eopts.allow_domain_literal { + email_opts = email_opts.with_domain_literal(); + } else { + email_opts = email_opts.without_domain_literal(); + } + if eopts.allow_display_text { + email_opts = email_opts.with_display_text(); + } else { + email_opts = email_opts.without_display_text(); + } + if let Some(min) = eopts.minimum_sub_domains { + email_opts = email_opts.with_minimum_sub_domains(min); + } + opts = opts.with_email_options(email_opts); + } + + if let Some(val) = http_options_val { + let hopts: &HttpOptions = magnus::TryConvert::try_convert(val).map_err(|_| { + Error::new( + ruby.exception_type_error(), + "http_options must be an HttpOptions instance", + ) + })?; + let mut http_opts = jsonschema::HttpOptions::new(); + if let Some(timeout) = hopts.timeout { + http_opts = http_opts.timeout(timeout_duration(ruby, "timeout", timeout)?); + } + if let Some(connect_timeout) = hopts.connect_timeout { + http_opts = http_opts.connect_timeout(timeout_duration( + ruby, + "connect_timeout", + connect_timeout, + )?); + } + if !hopts.tls_verify { + http_opts = http_opts.danger_accept_invalid_certs(true); + } + if let Some(ref ca_cert) = hopts.ca_cert { + http_opts = http_opts.add_root_certificate(ca_cert); + } + opts = opts + .with_http_options(&http_opts) + .map_err(|e| Error::new(ruby.exception_arg_error(), e.to_string()))?; + } + + Ok(ParsedOptions { + mask, + options: opts, + retriever, + has_ruby_callbacks, + callback_roots, + compilation_roots, + }) +} + +#[magnus::wrap(class = "JSONSchema::EmailOptions", free_immediately, size)] +pub struct EmailOptions { + pub require_tld: bool, + pub allow_domain_literal: bool, + pub allow_display_text: bool, + pub minimum_sub_domains: Option, +} + +impl EmailOptions { + #[allow(clippy::type_complexity)] + fn new_impl(args: &[Value]) -> Result { + let parsed = scan_args::<(), (), (), (), _, ()>(args)?; + let ids = [ + *KW_REQUIRE_TLD, + *KW_ALLOW_DOMAIN_LITERAL, + *KW_ALLOW_DISPLAY_TEXT, + *KW_MINIMUM_SUB_DOMAINS, + ]; + let kw: KwArgs<(), (Option, Option, Option, Option), ()> = + get_kwargs(parsed.keywords, &[], &ids)?; + let (require_tld, allow_domain_literal, allow_display_text, minimum_sub_domains) = + kw.optional; + Ok(EmailOptions { + require_tld: require_tld.unwrap_or(false), + allow_domain_literal: allow_domain_literal.unwrap_or(true), + allow_display_text: allow_display_text.unwrap_or(true), + minimum_sub_domains, + }) + } + + fn require_tld(&self) -> bool { + self.require_tld + } + + fn allow_domain_literal(&self) -> bool { + self.allow_domain_literal + } + + fn allow_display_text(&self) -> bool { + self.allow_display_text + } + + fn minimum_sub_domains(&self) -> Option { + self.minimum_sub_domains + } + + fn inspect(&self) -> String { + use std::fmt::Write; + let mut s = String::from("# s.push_str("nil"), + } + s.push('>'); + s + } +} + +#[magnus::wrap(class = "JSONSchema::RegexOptions", free_immediately, size)] +pub struct RegexOptions { + pub size_limit: Option, + pub dfa_size_limit: Option, +} + +impl RegexOptions { + fn new_impl(args: &[Value]) -> Result { + let parsed = scan_args::<(), (), (), (), _, ()>(args)?; + let ids = [*KW_SIZE_LIMIT, *KW_DFA_SIZE_LIMIT]; + let kw: KwArgs<(), (Option, Option), ()> = + get_kwargs(parsed.keywords, &[], &ids)?; + let (size_limit, dfa_size_limit) = kw.optional; + Ok(RegexOptions { + size_limit, + dfa_size_limit, + }) + } + + fn size_limit(&self) -> Option { + self.size_limit + } + + fn dfa_size_limit(&self) -> Option { + self.dfa_size_limit + } + + fn inspect(&self) -> String { + use std::fmt::Write; + let mut s = String::from("# s.push_str("nil"), + } + s.push_str(", dfa_size_limit="); + match self.dfa_size_limit { + Some(n) => write!(s, "{n}").expect("Failed to write dfa_size_limit"), + None => s.push_str("nil"), + } + s.push('>'); + s + } +} + +#[magnus::wrap(class = "JSONSchema::FancyRegexOptions", free_immediately, size)] +pub struct FancyRegexOptions { + pub backtrack_limit: Option, + pub size_limit: Option, + pub dfa_size_limit: Option, +} + +impl FancyRegexOptions { + #[allow(clippy::type_complexity)] + fn new_impl(args: &[Value]) -> Result { + let parsed = scan_args::<(), (), (), (), _, ()>(args)?; + let ids = [*KW_BACKTRACK_LIMIT, *KW_SIZE_LIMIT, *KW_DFA_SIZE_LIMIT]; + let kw: KwArgs<(), (Option, Option, Option), ()> = + get_kwargs(parsed.keywords, &[], &ids)?; + let (backtrack_limit, size_limit, dfa_size_limit) = kw.optional; + Ok(FancyRegexOptions { + backtrack_limit, + size_limit, + dfa_size_limit, + }) + } + + fn backtrack_limit(&self) -> Option { + self.backtrack_limit + } + + fn size_limit(&self) -> Option { + self.size_limit + } + + fn dfa_size_limit(&self) -> Option { + self.dfa_size_limit + } + + fn inspect(&self) -> String { + use std::fmt::Write; + let mut s = String::from("# s.push_str("nil"), + } + s.push_str(", size_limit="); + match self.size_limit { + Some(n) => write!(s, "{n}").expect("Failed to write size_limit"), + None => s.push_str("nil"), + } + s.push_str(", dfa_size_limit="); + match self.dfa_size_limit { + Some(n) => write!(s, "{n}").expect("Failed to write dfa_size_limit"), + None => s.push_str("nil"), + } + s.push('>'); + s + } +} + +#[magnus::wrap(class = "JSONSchema::HttpOptions", free_immediately, size)] +pub struct HttpOptions { + pub timeout: Option, + pub connect_timeout: Option, + pub tls_verify: bool, + pub ca_cert: Option, +} + +impl HttpOptions { + #[allow(clippy::type_complexity)] + fn new_impl(args: &[Value]) -> Result { + let parsed = scan_args::<(), (), (), (), _, ()>(args)?; + let ids = [ + *KW_TIMEOUT, + *KW_CONNECT_TIMEOUT, + *KW_TLS_VERIFY, + *KW_CA_CERT, + ]; + let kw: KwArgs<(), (Option, Option, Option, Option), ()> = + get_kwargs(parsed.keywords, &[], &ids)?; + let (timeout, connect_timeout, tls_verify, ca_cert) = kw.optional; + Ok(HttpOptions { + timeout, + connect_timeout, + tls_verify: tls_verify.unwrap_or(true), + ca_cert, + }) + } + + fn timeout(&self) -> Option { + self.timeout + } + + fn connect_timeout(&self) -> Option { + self.connect_timeout + } + + fn tls_verify(&self) -> bool { + self.tls_verify + } + + fn ca_cert(&self) -> Option { + self.ca_cert.clone() + } + + fn inspect(&self) -> String { + use std::fmt::Write; + let mut s = String::from("# s.push_str("nil"), + } + s.push_str(", connect_timeout="); + match self.connect_timeout { + Some(t) => write!(s, "{t}").expect("Failed to write connect_timeout"), + None => s.push_str("nil"), + } + s.push_str(", tls_verify="); + s.push_str(if self.tls_verify { "true" } else { "false" }); + s.push_str(", ca_cert="); + match &self.ca_cert { + Some(c) => write!(s, "\"{c}\"").expect("Failed to write ca_cert"), + None => s.push_str("nil"), + } + s.push('>'); + s + } +} + +pub fn define_classes(ruby: &Ruby, module: &RModule) -> Result<(), Error> { + let email_class = module.define_class("EmailOptions", ruby.class_object())?; + email_class.define_singleton_method("new", function!(EmailOptions::new_impl, -1))?; + email_class.define_method("require_tld", method!(EmailOptions::require_tld, 0))?; + email_class.define_method( + "allow_domain_literal", + method!(EmailOptions::allow_domain_literal, 0), + )?; + email_class.define_method( + "allow_display_text", + method!(EmailOptions::allow_display_text, 0), + )?; + email_class.define_method( + "minimum_sub_domains", + method!(EmailOptions::minimum_sub_domains, 0), + )?; + email_class.define_method("inspect", method!(EmailOptions::inspect, 0))?; + + let regex_class = module.define_class("RegexOptions", ruby.class_object())?; + regex_class.define_singleton_method("new", function!(RegexOptions::new_impl, -1))?; + regex_class.define_method("size_limit", method!(RegexOptions::size_limit, 0))?; + regex_class.define_method("dfa_size_limit", method!(RegexOptions::dfa_size_limit, 0))?; + regex_class.define_method("inspect", method!(RegexOptions::inspect, 0))?; + + let fancy_regex_class = module.define_class("FancyRegexOptions", ruby.class_object())?; + fancy_regex_class.define_singleton_method("new", function!(FancyRegexOptions::new_impl, -1))?; + fancy_regex_class.define_method( + "backtrack_limit", + method!(FancyRegexOptions::backtrack_limit, 0), + )?; + fancy_regex_class.define_method("size_limit", method!(FancyRegexOptions::size_limit, 0))?; + fancy_regex_class.define_method( + "dfa_size_limit", + method!(FancyRegexOptions::dfa_size_limit, 0), + )?; + fancy_regex_class.define_method("inspect", method!(FancyRegexOptions::inspect, 0))?; + + let http_class = module.define_class("HttpOptions", ruby.class_object())?; + http_class.define_singleton_method("new", function!(HttpOptions::new_impl, -1))?; + http_class.define_method("timeout", method!(HttpOptions::timeout, 0))?; + http_class.define_method("connect_timeout", method!(HttpOptions::connect_timeout, 0))?; + http_class.define_method("tls_verify", method!(HttpOptions::tls_verify, 0))?; + http_class.define_method("ca_cert", method!(HttpOptions::ca_cert, 0))?; + http_class.define_method("inspect", method!(HttpOptions::inspect, 0))?; + + Ok(()) +} diff --git a/crates/jsonschema-rb/src/registry.rs b/crates/jsonschema-rb/src/registry.rs new file mode 100644 index 000000000..a6d9159dd --- /dev/null +++ b/crates/jsonschema-rb/src/registry.rs @@ -0,0 +1,138 @@ +use magnus::{ + function, + gc::{register_address, unregister_address}, + method, + prelude::*, + scan_args::{get_kwargs, scan_args}, + value::Opaque, + DataTypeFunctions, Error, RArray, RModule, Ruby, TryConvert, Value, +}; + +use crate::{options::parse_draft_symbol, retriever::make_retriever, ser::to_value}; + +struct RetrieverBuildRootGuard { + // Keep roots in a heap allocation so addresses passed to Ruby GC are stable + // even if the guard value itself is moved. + roots: Vec, +} + +impl RetrieverBuildRootGuard { + fn new(root: Option) -> Self { + let mut roots = Vec::new(); + if let Some(value) = root { + roots.push(value); + } + for value in &roots { + register_address(value); + } + Self { roots } + } +} + +impl Drop for RetrieverBuildRootGuard { + fn drop(&mut self) { + for value in &self.roots { + unregister_address(value); + } + } +} + +#[derive(magnus::TypedData)] +#[magnus(class = "JSONSchema::Registry", free_immediately, size, mark)] +pub struct Registry { + pub inner: jsonschema::Registry, + retriever_root: Option>, +} + +impl DataTypeFunctions for Registry { + fn mark(&self, marker: &magnus::gc::Marker) { + if let Some(root) = self.retriever_root { + marker.mark(root); + } + } +} + +impl TryConvert for Registry { + fn try_convert(val: Value) -> Result { + let typed: &Registry = TryConvert::try_convert(val)?; + Ok(Registry { + inner: typed.inner.clone(), + retriever_root: typed.retriever_root, + }) + } +} + +impl Registry { + fn new_impl(ruby: &Ruby, args: &[Value]) -> Result { + let parsed_args = scan_args::<(RArray,), (), (), (), _, ()>(args)?; + let (resources,) = parsed_args.required; + #[allow(clippy::type_complexity)] + let kw: magnus::scan_args::KwArgs<(), (Option>, Option), ()> = + get_kwargs(parsed_args.keywords, &[], &["draft", "retriever"])?; + let draft_val = kw.optional.0.flatten(); + let retriever_val = kw.optional.1; + + let mut builder = jsonschema::Registry::options(); + let mut retriever_root = None; + let mut retriever_build_root = None; + + if let Some(val) = draft_val { + let draft = parse_draft_symbol(ruby, val)?; + builder = builder.draft(draft); + } + + if let Some(val) = retriever_val { + if let Some(ret) = make_retriever(ruby, val)? { + builder = builder.retriever(ret); + retriever_root = Some(Opaque::from(val)); + retriever_build_root = Some(val); + } + } + + let pairs: Vec<(String, jsonschema::Resource)> = resources + .into_iter() + .map(|item| { + let pair: RArray = TryConvert::try_convert(item)?; + if pair.len() != 2 { + return Err(Error::new( + ruby.exception_arg_error(), + "Each resource must be a [uri, schema] pair", + )); + } + let uri: String = pair.entry(0)?; + let schema_val: Value = pair.entry(1)?; + let schema = to_value(ruby, schema_val)?; + let resource = jsonschema::Resource::from_contents(schema); + Ok((uri, resource)) + }) + .collect::, Error>>()?; + + // Keep the retriever proc GC-rooted for the entire build, because `build` + // may call into retriever callbacks while traversing referenced resources. + let _retriever_build_guard = RetrieverBuildRootGuard::new(retriever_build_root); + let registry = builder + .build(pairs) + .map_err(|e| Error::new(ruby.exception_arg_error(), e.to_string()))?; + + Ok(Registry { + inner: registry, + retriever_root, + }) + } + + fn inspect(&self) -> String { + "#".to_string() + } + + pub(crate) fn retriever_value(&self, ruby: &Ruby) -> Option { + self.retriever_root.map(|root| ruby.get_inner(root)) + } +} + +pub fn define_class(ruby: &Ruby, module: &RModule) -> Result<(), Error> { + let class = module.define_class("Registry", ruby.class_object())?; + class.define_singleton_method("new", function!(Registry::new_impl, -1))?; + class.define_method("inspect", method!(Registry::inspect, 0))?; + + Ok(()) +} diff --git a/crates/jsonschema-rb/src/retriever.rs b/crates/jsonschema-rb/src/retriever.rs new file mode 100644 index 000000000..425514fef --- /dev/null +++ b/crates/jsonschema-rb/src/retriever.rs @@ -0,0 +1,100 @@ +//! Retriever callback wrapper for Ruby. +use jsonschema::{Retrieve, Uri}; +use magnus::{block::Proc, prelude::*, value::Opaque, Error, Ruby, Value}; +use serde_json::Value as JsonValue; + +use crate::ser::to_value; + +#[derive(Debug)] +pub enum RubyRetrieverError { + ReturnedNil { uri: String }, + ConversionFailed { message: String }, + CallbackFailed { uri: String, message: String }, +} + +impl std::fmt::Display for RubyRetrieverError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ReturnedNil { uri } => write!(f, "Retriever returned nil for URI: {uri}"), + Self::ConversionFailed { message } => { + write!(f, "Failed to convert retriever result: {message}") + } + Self::CallbackFailed { uri, message } => { + write!(f, "Retriever failed for {uri}: {message}") + } + } + } +} + +impl std::error::Error for RubyRetrieverError {} + +pub fn retriever_error_message(error: &(dyn std::error::Error + 'static)) -> Option { + let mut current: Option<&(dyn std::error::Error + 'static)> = Some(error); + while let Some(err) = current { + if let Some(retriever_error) = err.downcast_ref::() { + return Some(retriever_error.to_string()); + } + current = err.source(); + } + None +} + +pub struct RubyRetriever { + proc: Opaque, +} + +impl RubyRetriever { + pub fn new(proc: Proc) -> Self { + RubyRetriever { + proc: Opaque::from(proc), + } + } +} + +impl Retrieve for RubyRetriever { + fn retrieve( + &self, + uri: &Uri, + ) -> Result> { + let ruby = Ruby::get().expect("Ruby VM should be initialized"); + let uri_str = uri.as_str(); + let proc = ruby.get_inner(self.proc); + + let result: Result = proc.call((uri_str,)); + + match result { + Ok(value) => { + if value.is_nil() { + return Err(Box::new(RubyRetrieverError::ReturnedNil { + uri: uri_str.to_owned(), + })); + } + to_value(&ruby, value).map_err(|e| { + Box::new(RubyRetrieverError::ConversionFailed { + message: e.to_string(), + }) as Box + }) + } + Err(e) => Err(Box::new(RubyRetrieverError::CallbackFailed { + uri: uri_str.to_owned(), + message: e.to_string(), + })), + } + } +} + +/// Convert a Ruby value (should be a Proc) to a retriever, if present +pub fn make_retriever(ruby: &Ruby, value: Value) -> Result, Error> { + if value.is_nil() { + return Ok(None); + } + + let proc = Proc::from_value(value).ok_or_else(|| { + Error::new( + ruby.exception_type_error(), + "Retriever must be a callable (Proc or Lambda)", + ) + })?; + + Ok(Some(RubyRetriever::new(proc))) +} diff --git a/crates/jsonschema-rb/src/ser.rs b/crates/jsonschema-rb/src/ser.rs new file mode 100644 index 000000000..9dc2673da --- /dev/null +++ b/crates/jsonschema-rb/src/ser.rs @@ -0,0 +1,724 @@ +//! Serialization between Ruby values and `serde_json::Value`. +use magnus::{ + gc::register_mark_object, + prelude::*, + rb_sys::AsRawValue, + value::{Lazy, ReprValue}, + Error, Integer, RArray, RClass, RHash, RString, Ruby, Symbol, TryConvert, Value, +}; +use rb_sys::{ruby_value_type, RB_TYPE}; +use serde_json::{Map, Number, Value as JsonValue}; +use std::fmt; + +static BIG_DECIMAL_CLASS: Lazy = Lazy::new(|ruby| { + // Ensure bigdecimal is loaded + let _: Value = ruby + .eval("require 'bigdecimal'") + .expect("Failed to require bigdecimal"); + let cls: RClass = ruby + .eval("BigDecimal") + .expect("BigDecimal class must exist"); + register_mark_object(cls); + cls +}); + +const RECURSION_LIMIT: u16 = 255; + +#[inline] +pub fn to_value(ruby: &Ruby, value: Value) -> Result { + to_value_recursive(ruby, value, 0) +} + +/// Convert a Ruby value in schema position to a `serde_json::Value`. +/// +/// If the value is a String, attempt to parse it as JSON first. +/// This allows passing JSON strings as schemas (e.g. `'{"type":"integer"}'`). +/// If parsing fails, falls back to treating it as a plain string value. +#[inline] +pub fn to_schema_value(ruby: &Ruby, value: Value) -> Result { + // SAFETY: We're reading the type tag of a valid Ruby value + #[allow(unsafe_code)] + let value_type = unsafe { RB_TYPE(value.as_raw()) }; + if value_type == ruby_value_type::RUBY_T_STRING { + if let Some(rstring) = RString::from_value(value) { + // SAFETY: rstring is valid and we're in Ruby VM context + #[allow(unsafe_code)] + let bytes = unsafe { rstring.as_slice() }; + if let Ok(parsed) = serde_json::from_slice(bytes) { + return Ok(parsed); + } + } + } + to_value_typed(ruby, value, value_type, 0) +} + +fn to_value_recursive(ruby: &Ruby, value: Value, depth: u16) -> Result { + if value.is_nil() { + return Ok(JsonValue::Null); + } + + // SAFETY: We're reading the type tag of a valid Ruby value + #[allow(unsafe_code)] + let value_type = unsafe { RB_TYPE(value.as_raw()) }; + + to_value_typed(ruby, value, value_type, depth) +} + +fn to_value_typed( + ruby: &Ruby, + value: Value, + value_type: ruby_value_type, + depth: u16, +) -> Result { + match value_type { + ruby_value_type::RUBY_T_TRUE => Ok(JsonValue::Bool(true)), + ruby_value_type::RUBY_T_FALSE => Ok(JsonValue::Bool(false)), + ruby_value_type::RUBY_T_FIXNUM | ruby_value_type::RUBY_T_BIGNUM => { + convert_integer(ruby, value) + } + ruby_value_type::RUBY_T_FLOAT => { + let f = f64::try_convert(value)?; + Number::from_f64(f).map(JsonValue::Number).ok_or_else(|| { + Error::new( + ruby.exception_arg_error(), + "Cannot convert NaN or Infinity to JSON", + ) + }) + } + ruby_value_type::RUBY_T_STRING => { + let Some(rstring) = RString::from_value(value) else { + unreachable!("We checked the type tag") + }; + // SAFETY: rstring is valid and we're in Ruby VM context + #[allow(unsafe_code)] + let bytes = unsafe { rstring.as_slice() }; + match std::str::from_utf8(bytes) { + Ok(s) => Ok(JsonValue::String(s.to_owned())), + Err(_) => Err(Error::new( + ruby.exception_encoding_error(), + "String is not valid UTF-8", + )), + } + } + ruby_value_type::RUBY_T_SYMBOL => { + let Some(sym) = Symbol::from_value(value) else { + unreachable!("We checked the type tag") + }; + let name = sym.name()?; + Ok(JsonValue::String(name.to_string())) + } + ruby_value_type::RUBY_T_ARRAY => { + if depth >= RECURSION_LIMIT { + return Err(Error::new( + ruby.exception_arg_error(), + format!("Exceeded maximum nesting depth ({RECURSION_LIMIT})"), + )); + } + let Some(arr) = RArray::from_value(value) else { + unreachable!("We checked the type tag") + }; + let len = arr.len(); + let mut json_arr = Vec::with_capacity(len); + // Do not use `RArray::as_slice` here: recursive conversion may call + // Ruby APIs for nested values, and `as_slice` borrows Ruby-managed + // memory that must not be held across Ruby calls/GC. + for idx in 0..len { + let idx = isize::try_from(idx).map_err(|_| { + Error::new( + ruby.exception_arg_error(), + "Array index exceeds supported range", + ) + })?; + let item: Value = arr.entry(idx)?; + json_arr.push(to_value_recursive(ruby, item, depth + 1)?); + } + Ok(JsonValue::Array(json_arr)) + } + ruby_value_type::RUBY_T_HASH => { + if depth >= RECURSION_LIMIT { + return Err(Error::new( + ruby.exception_arg_error(), + format!("Exceeded maximum nesting depth ({RECURSION_LIMIT})"), + )); + } + let Some(hash) = RHash::from_value(value) else { + unreachable!("We checked the type tag") + }; + let mut map = Map::with_capacity(hash.len()); + hash.foreach(|key: Value, val: Value| { + let key_str = hash_key_to_string(ruby, key)?; + let json_val = to_value_recursive(ruby, val, depth + 1)?; + map.insert(key_str, json_val); + Ok(magnus::r_hash::ForEach::Continue) + })?; + Ok(JsonValue::Object(map)) + } + ruby_value_type::RUBY_T_DATA if value.is_kind_of(ruby.get_inner(&BIG_DECIMAL_CLASS)) => { + convert_big_decimal(ruby, value) + } + _ => { + let class = value.class(); + #[allow(unsafe_code)] + let class_name = unsafe { class.name() }; + Err(Error::new( + ruby.exception_type_error(), + format!("Unsupported type: '{class_name}'"), + )) + } + } +} + +/// Convert Ruby BigDecimal to JSON Number while preserving precision. +#[inline] +fn convert_big_decimal(ruby: &Ruby, value: Value) -> Result { + let decimal_text: String = value.funcall("to_s", ("F",))?; + if let Ok(JsonValue::Number(n)) = serde_json::from_str::(&decimal_text) { + return Ok(JsonValue::Number(n)); + } + Err(Error::new( + ruby.exception_arg_error(), + "Cannot convert BigDecimal NaN or Infinity to JSON", + )) +} + +/// Convert Ruby Integer to JSON Number +/// Handles Fixnum, Bignum, and arbitrary precision +#[inline] +fn convert_integer(ruby: &Ruby, value: Value) -> Result { + // Try i64 first (handles most integers including negative fixnums) + if let Ok(i) = i64::try_convert(value) { + return Ok(JsonValue::Number(Number::from(i))); + } + + // For bignums, try Integer methods + if let Some(int) = Integer::from_value(value) { + // Try u64 for large positive integers + if let Ok(u) = int.to_u64() { + return Ok(JsonValue::Number(Number::from(u))); + } + // Arbitrary precision via string parsing + let s: String = int.funcall("to_s", ())?; + if let Ok(JsonValue::Number(n)) = serde_json::from_str::(&s) { + return Ok(JsonValue::Number(n)); + } + } + + Err(Error::new( + ruby.exception_type_error(), + "Cannot convert Integer to JSON", + )) +} + +#[inline] +fn hash_key_to_string(ruby: &Ruby, key: Value) -> Result { + #[allow(unsafe_code)] + let key_type = unsafe { RB_TYPE(key.as_raw()) }; + + match key_type { + ruby_value_type::RUBY_T_STRING => { + if let Some(rstring) = RString::from_value(key) { + // SAFETY: rstring is valid + #[allow(unsafe_code)] + let bytes = unsafe { rstring.as_slice() }; + return std::str::from_utf8(bytes) + .map(std::borrow::ToOwned::to_owned) + .map_err(|_| { + Error::new( + ruby.exception_encoding_error(), + "Hash key is not valid UTF-8", + ) + }); + } + } + ruby_value_type::RUBY_T_SYMBOL => { + if let Some(sym) = Symbol::from_value(key) { + return Ok(sym.name()?.to_string()); + } + } + _ => {} + } + + Err(Error::new( + ruby.exception_type_error(), + "Hash keys must be strings or symbols", + )) +} + +#[inline] +pub fn map_to_ruby(ruby: &Ruby, map: &Map) -> Result { + let rb_hash = ruby.hash_new_capa(map.len()); + for (k, v) in map { + rb_hash.aset(k.as_str(), value_to_ruby(ruby, v)?)?; + } + Ok(rb_hash.as_value()) +} + +#[inline] +pub fn value_to_ruby(ruby: &Ruby, value: &JsonValue) -> Result { + match value { + JsonValue::Null => Ok(ruby.qnil().as_value()), + JsonValue::Bool(b) => Ok(ruby.into_value(*b)), + JsonValue::Number(n) => number_to_ruby(ruby, n), + JsonValue::String(s) => Ok(ruby.into_value(s.as_str())), + JsonValue::Array(arr) => { + let rb_arr = ruby.ary_new_capa(arr.len()); + for item in arr { + rb_arr.push(value_to_ruby(ruby, item)?)?; + } + Ok(rb_arr.as_value()) + } + JsonValue::Object(obj) => { + let rb_hash = ruby.hash_new_capa(obj.len()); + for (k, v) in obj { + rb_hash.aset(k.as_str(), value_to_ruby(ruby, v)?)?; + } + Ok(rb_hash.as_value()) + } + } +} + +#[inline] +fn number_to_ruby(ruby: &Ruby, number: &Number) -> Result { + if let Some(i) = number.as_i64() { + return Ok(ruby.into_value(i)); + } + if let Some(u) = number.as_u64() { + return Ok(ruby.integer_from_u64(u).as_value()); + } + number_string_to_ruby(ruby, &number.to_string()) +} + +#[inline] +fn number_string_to_ruby(ruby: &Ruby, number: &str) -> Result { + if !number.contains(['.', 'e', 'E']) { + return ruby.module_kernel().funcall("Integer", (number,)); + } + + if let Ok(f) = number.parse::() { + if f.is_finite() + && Number::from_f64(f).is_some_and(|roundtrip| roundtrip.to_string() == number) + { + return Ok(ruby.into_value(f)); + } + } + + let _ = ruby.get_inner(&BIG_DECIMAL_CLASS); + ruby.module_kernel().funcall("BigDecimal", (number,)) +} + +/// Token used by serde_json with the `arbitrary_precision` feature. +const SERDE_JSON_NUMBER_TOKEN: &str = "$serde_json::private::Number"; + +#[derive(Debug)] +struct RubySerError(String); + +impl fmt::Display for RubySerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for RubySerError {} + +impl serde::ser::Error for RubySerError { + fn custom(msg: T) -> Self { + RubySerError(msg.to_string()) + } +} + +/// A [`serde::Serializer`] that directly produces Ruby [`Value`] objects. +#[derive(Clone, Copy)] +struct RubySerializer<'a> { + ruby: &'a Ruby, +} + +impl<'a> RubySerializer<'a> { + fn new(ruby: &'a Ruby) -> Self { + RubySerializer { ruby } + } + + /// Parse a raw number string into a Ruby Integer, Float, or BigDecimal. + fn parse_number(&self, s: &str) -> Result { + number_string_to_ruby(self.ruby, s) + .map_err(|e| RubySerError(format!("number conversion failed: {e}"))) + } +} + +impl<'a> serde::Serializer for RubySerializer<'a> { + type Ok = Value; + type Error = RubySerError; + + type SerializeSeq = RubySeqSerializer<'a>; + type SerializeTuple = RubySeqSerializer<'a>; + type SerializeTupleStruct = RubySeqSerializer<'a>; + type SerializeTupleVariant = RubySeqSerializer<'a>; + type SerializeMap = RubyMapSerializer<'a>; + type SerializeStruct = RubyStructSerializer<'a>; + type SerializeStructVariant = RubyStructSerializer<'a>; + + #[inline] + fn serialize_bool(self, v: bool) -> Result { + Ok(self.ruby.into_value(v)) + } + + #[inline] + fn serialize_i8(self, v: i8) -> Result { + self.serialize_i64(i64::from(v)) + } + + #[inline] + fn serialize_i16(self, v: i16) -> Result { + self.serialize_i64(i64::from(v)) + } + + #[inline] + fn serialize_i32(self, v: i32) -> Result { + self.serialize_i64(i64::from(v)) + } + + #[inline] + fn serialize_i64(self, v: i64) -> Result { + Ok(self.ruby.into_value(v)) + } + + #[inline] + fn serialize_u8(self, v: u8) -> Result { + self.serialize_u64(u64::from(v)) + } + + #[inline] + fn serialize_u16(self, v: u16) -> Result { + self.serialize_u64(u64::from(v)) + } + + #[inline] + fn serialize_u32(self, v: u32) -> Result { + self.serialize_u64(u64::from(v)) + } + + #[inline] + fn serialize_u64(self, v: u64) -> Result { + Ok(self.ruby.integer_from_u64(v).as_value()) + } + + #[inline] + fn serialize_f32(self, v: f32) -> Result { + self.serialize_f64(f64::from(v)) + } + + #[inline] + fn serialize_f64(self, v: f64) -> Result { + Ok(self.ruby.into_value(v)) + } + + #[inline] + fn serialize_char(self, v: char) -> Result { + let mut buf = [0u8; 4]; + Ok(self.ruby.into_value(v.encode_utf8(&mut buf) as &str)) + } + + #[inline] + fn serialize_str(self, v: &str) -> Result { + Ok(self.ruby.into_value(v)) + } + + fn serialize_bytes(self, v: &[u8]) -> Result { + Ok(self.ruby.str_from_slice(v).as_value()) + } + + #[inline] + fn serialize_none(self) -> Result { + Ok(self.ruby.qnil().as_value()) + } + + #[inline] + fn serialize_some( + self, + value: &T, + ) -> Result { + value.serialize(self) + } + + #[inline] + fn serialize_unit(self) -> Result { + Ok(self.ruby.qnil().as_value()) + } + + #[inline] + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Ok(self.ruby.qnil().as_value()) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result { + Ok(self.ruby.into_value(variant)) + } + + fn serialize_newtype_struct( + self, + name: &'static str, + value: &T, + ) -> Result { + if name == SERDE_JSON_NUMBER_TOKEN { + // inner serializes as a raw number string. + // Serialize to Ruby String first, then parse as number. + let rb_str = value.serialize(self)?; + if let Some(rstring) = RString::from_value(rb_str) { + let number = { + // Copy bytes into an owned String before calling Ruby: + // `parse_number` may invoke `Kernel.Integer` / `BigDecimal`, + // so keeping an `as_slice` borrow alive would be unsound. + // SAFETY: `rstring` is valid and we're in Ruby VM context. + #[allow(unsafe_code)] + let bytes = unsafe { rstring.as_slice() }; + std::str::from_utf8(bytes) + .map(std::borrow::ToOwned::to_owned) + .map_err(|_| { + serde::ser::Error::custom("invalid arbitrary precision number") + })? + }; + return self.parse_number(&number); + } + return Err(serde::ser::Error::custom( + "invalid arbitrary precision number", + )); + } + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> Result { + value.serialize(self) + } + + fn serialize_seq(self, len: Option) -> Result, RubySerError> { + let arr = match len { + Some(n) => self.ruby.ary_new_capa(n), + None => self.ruby.ary_new(), + }; + Ok(RubySeqSerializer { + ruby: self.ruby, + arr, + }) + } + + fn serialize_tuple(self, len: usize) -> Result, RubySerError> { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + len: usize, + ) -> Result, RubySerError> { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + len: usize, + ) -> Result, RubySerError> { + self.serialize_seq(Some(len)) + } + + fn serialize_map(self, len: Option) -> Result, RubySerError> { + let hash = match len { + Some(n) => self.ruby.hash_new_capa(n), + None => self.ruby.hash_new(), + }; + Ok(RubyMapSerializer { + ruby: self.ruby, + hash, + next_key: None, + }) + } + + fn serialize_struct( + self, + _name: &'static str, + len: usize, + ) -> Result, RubySerError> { + Ok(RubyStructSerializer { + ruby: self.ruby, + hash: self.ruby.hash_new_capa(len), + }) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + len: usize, + ) -> Result, RubySerError> { + Ok(RubyStructSerializer { + ruby: self.ruby, + hash: self.ruby.hash_new_capa(len), + }) + } +} + +/// Sequence serializer producing Ruby Arrays. +struct RubySeqSerializer<'a> { + ruby: &'a Ruby, + arr: RArray, +} + +impl serde::ser::SerializeSeq for RubySeqSerializer<'_> { + type Ok = Value; + type Error = RubySerError; + + fn serialize_element( + &mut self, + value: &T, + ) -> Result<(), RubySerError> { + let v = value.serialize(RubySerializer::new(self.ruby))?; + self.arr.push(v).map_err(serde::ser::Error::custom) + } + + fn end(self) -> Result { + Ok(self.arr.as_value()) + } +} + +impl serde::ser::SerializeTuple for RubySeqSerializer<'_> { + type Ok = Value; + type Error = RubySerError; + + fn serialize_element( + &mut self, + value: &T, + ) -> Result<(), RubySerError> { + serde::ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + serde::ser::SerializeSeq::end(self) + } +} + +impl serde::ser::SerializeTupleStruct for RubySeqSerializer<'_> { + type Ok = Value; + type Error = RubySerError; + + fn serialize_field( + &mut self, + value: &T, + ) -> Result<(), RubySerError> { + serde::ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + serde::ser::SerializeSeq::end(self) + } +} + +impl serde::ser::SerializeTupleVariant for RubySeqSerializer<'_> { + type Ok = Value; + type Error = RubySerError; + + fn serialize_field( + &mut self, + value: &T, + ) -> Result<(), RubySerError> { + serde::ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + serde::ser::SerializeSeq::end(self) + } +} + +/// Map serializer producing Ruby Hashes. +struct RubyMapSerializer<'a> { + ruby: &'a Ruby, + hash: RHash, + next_key: Option, +} + +impl serde::ser::SerializeMap for RubyMapSerializer<'_> { + type Ok = Value; + type Error = RubySerError; + + fn serialize_key(&mut self, key: &T) -> Result<(), RubySerError> { + self.next_key = Some(key.serialize(RubySerializer::new(self.ruby))?); + Ok(()) + } + + fn serialize_value( + &mut self, + value: &T, + ) -> Result<(), RubySerError> { + let key = self + .next_key + .take() + .expect("serialize_value called without serialize_key"); + let val = value.serialize(RubySerializer::new(self.ruby))?; + self.hash.aset(key, val).map_err(serde::ser::Error::custom) + } + + fn end(self) -> Result { + Ok(self.hash.as_value()) + } +} + +/// Struct serializer producing Ruby Hashes with symbol keys. +struct RubyStructSerializer<'a> { + ruby: &'a Ruby, + hash: RHash, +} + +impl serde::ser::SerializeStruct for RubyStructSerializer<'_> { + type Ok = Value; + type Error = RubySerError; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), RubySerError> { + let val = value.serialize(RubySerializer::new(self.ruby))?; + let sym = self.ruby.sym_new(key); + self.hash.aset(sym, val).map_err(serde::ser::Error::custom) + } + + fn end(self) -> Result { + Ok(self.hash.as_value()) + } +} + +impl serde::ser::SerializeStructVariant for RubyStructSerializer<'_> { + type Ok = Value; + type Error = RubySerError; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), RubySerError> { + serde::ser::SerializeStruct::serialize_field(self, key, value) + } + + fn end(self) -> Result { + serde::ser::SerializeStruct::end(self) + } +} + +/// Serialize any [`serde::Serialize`] type directly to a Ruby [`Value`]. +pub fn serialize_to_ruby(ruby: &Ruby, value: &T) -> Result { + value + .serialize(RubySerializer::new(ruby)) + .map_err(|err| Error::new(ruby.exception_runtime_error(), err.to_string())) +} diff --git a/crates/jsonschema-rb/src/static_id.rs b/crates/jsonschema-rb/src/static_id.rs new file mode 100644 index 000000000..93cb13dc7 --- /dev/null +++ b/crates/jsonschema-rb/src/static_id.rs @@ -0,0 +1,93 @@ +//! Pre-interned Ruby symbol IDs for avoiding repeated `rb_intern` calls. +//! +//! Ruby's `rb_intern` does a hash table lookup each time it's called with a string. +//! By interning strings once at startup and reusing the resulting IDs, we eliminate +//! repeated lookups in hot paths like error construction. + +use std::num::NonZeroUsize; + +use magnus::{ + rb_sys::{AsRawId, FromRawId}, + value::{Id, IntoId, StaticSymbol}, + Ruby, +}; +use rb_sys::ID; + +/// A thread-safe, pre-interned Ruby symbol ID. +/// +/// Unlike Magnus's `Id`, this type is `Send + Sync` and can be stored in +/// `static` variables via `LazyLock`. It wraps the raw Ruby `ID` value +/// (which is a process-global, immutable index once interned). +#[derive(Debug, Clone, Copy)] +pub struct StaticId(NonZeroUsize); + +// SAFETY: Ruby IDs are process-global, immutable integer indices into the +// symbol table. Once interned, they never change or get collected. +#[allow(unsafe_code)] +unsafe impl Send for StaticId {} +#[allow(unsafe_code)] +unsafe impl Sync for StaticId {} + +impl StaticId { + /// Intern a string and return a `StaticId`. + /// + /// # Safety + /// + /// Must be called from a Ruby thread. + #[allow(clippy::cast_possible_truncation, unsafe_code)] + pub unsafe fn intern(name: &str) -> Self { + let ruby = unsafe { Ruby::get_unchecked() }; + let id = ruby.intern(name); + StaticId(unsafe { NonZeroUsize::new_unchecked(id.as_raw() as usize) }) + } + + /// Get the raw Ruby `ID` value. + #[inline] + #[allow(clippy::cast_possible_truncation)] + pub fn as_raw(self) -> ID { + self.0.get() as ID + } + + /// Convert to a `StaticSymbol` for use as a hash key. + #[inline] + #[allow(unsafe_code)] + pub fn to_symbol(self) -> StaticSymbol { + let id: Id = unsafe { Id::from_raw(self.as_raw()) }; + StaticSymbol::from(id) + } +} + +impl IntoId for StaticId { + #[inline] + #[allow(unsafe_code)] + fn into_id_with(self, _handle: &Ruby) -> Id { + // SAFETY: The raw ID was obtained from a valid `rb_intern` call. + unsafe { Id::from_raw(self.as_raw()) } + } +} + +/// Define a lazily-interned static Ruby symbol ID. +/// +/// The ID is interned on first access (which must happen on a Ruby thread). +/// +/// # Example +/// +/// ```ignore +/// define_rb_intern!(static ID_MESSAGE: "@message"); +/// // Use with funcall: +/// obj.funcall(*ID_ALLOCATE, ())?; +/// // Use with ivar_set: +/// obj.ivar_set(*ID_MESSAGE, value)?; +/// ``` +macro_rules! define_rb_intern { + ($vis:vis static $name:ident : $lit:expr) => { + #[allow(unsafe_code)] + $vis static $name: std::sync::LazyLock<$crate::static_id::StaticId> = + std::sync::LazyLock::new(|| { + #[allow(unsafe_code)] + unsafe { $crate::static_id::StaticId::intern($lit) } + }); + }; +} + +pub(crate) use define_rb_intern; diff --git a/crates/jsonschema/src/types.rs b/crates/jsonschema/src/types.rs index 542a00d53..9b837629c 100644 --- a/crates/jsonschema/src/types.rs +++ b/crates/jsonschema/src/types.rs @@ -23,15 +23,7 @@ pub enum JsonType { impl fmt::Display for JsonType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - JsonType::Array => f.write_str("array"), - JsonType::Boolean => f.write_str("boolean"), - JsonType::Integer => f.write_str("integer"), - JsonType::Null => f.write_str("null"), - JsonType::Number => f.write_str("number"), - JsonType::Object => f.write_str("object"), - JsonType::String => f.write_str("string"), - } + f.write_str(self.as_str()) } }