diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8a8c438..c4b1767 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -36,22 +36,15 @@ jobs: # lockfile bump misses the bundler cache and installs a full Rails tree # from source. Still bounds a hang well under the 6-hour default. timeout-minutes: 30 - name: "rspec - Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }}" + name: "rspec - Ruby ${{ matrix.ruby }}" strategy: fail-fast: false matrix: - # Covers the supported range from the floor (Ruby 3.2 / Rails 6.1) to - # the current ceiling (Ruby 3.4 / Rails 8.1). Curated pairs avoid - # Ruby/Rails combinations that are not mutually supported. - include: - - { ruby: "3.2", gemfile: rails_6_1 } - - { ruby: "3.2", gemfile: rails_7_1 } - - { ruby: "3.3", gemfile: rails_7_2 } - - { ruby: "3.3", gemfile: rails_8_0 } - - { ruby: "3.4", gemfile: rails_8_0 } - - { ruby: "3.4", gemfile: rails_8_1 } + ruby: ["3.2", "3.3", "3.4", "4.0"] env: - BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile + # Rails lives in the gemspec's generator development group. Matcher tests + # prove that the runtime works without installing that dependency tree. + BUNDLE_WITHOUT: generators steps: # Both actions track their major tag, so upstream patch releases arrive # without a commit here. Dependabot opens a PR for each new major. @@ -65,6 +58,31 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true + - name: Run rspec + run: bundle exec rspec spec/rspec + + generators: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 30 + name: "generators - Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }}" + strategy: + fail-fast: false + matrix: + include: + - { ruby: "3.2", gemfile: rails_6_1 } + - { ruby: "3.4", gemfile: rails_8_1 } + env: + BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true - name: Run rspec run: bundle exec rspec diff --git a/.gitignore b/.gitignore index 162ddeb..523e7bd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ gemfiles/*.gemfile.lock # local mise tool configuration mise.toml + +# private project roadmap +/ROADMAP.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6515d50..2c5a10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,30 @@ ## [Unreleased] +### Fixed +- Exact arrays enforce full key structure for Hash elements. Extra null-valued keys and missing keys that happen to accept blank values no longer pass inside tuples. +- Nested key checks preserve each key's parent association. Objects with the same nested key names attached to different parents no longer match when the affected values are `null`. +- Regexp schemas require a String value instead of matching `to_s`. Numeric values and `null` no longer satisfy permissive regular expressions. + ### Changed +- Removed ActiveSupport blank/present core extensions in favor of a JSON-focused internal helper with the same relevant semantics. +- Runtime dependencies are now only `diffy` and `rspec-expectations`. Rails, Railties, ActiveSupport, and `rspec-rails` are no longer installed for matcher-only consumers. Projects using the optional generators must provide Rails themselves. +- The compatibility matrix tests matcher behavior without Rails on Ruby 3.2, 3.3, 3.4, and 4.0. Separate generator jobs exercise Rails 6.1 and Rails 8.1 at the supported boundaries. +- The README now documents strict key behavior, schema dispatch, errors, array shorthand, supported runtimes, and the fact that this is a general JSON shape matcher rather than a JSON:API implementation. - CI runs `actions/checkout@v7`, up from v5, alongside `ruby/setup-ruby@v1`. Both track their major tag, so upstream patch releases arrive without a commit here and Dependabot opens a pull request for each new major. Checkout also runs with `persist-credentials: false`: no step needs git credentials, and bundler evaluates gemspec and native-extension code from the branch under test. - The workflow runs once per change rather than twice. `push` is scoped to `master`, so a branch with an open pull request no longer builds under both events, and a `concurrency` group cancels superseded pull request runs. Runs on `master` are left alone, so every commit there keeps a result. - `rubocop` and `bundler-audit` share one job definition instead of two byte-identical ones, and every job carries `timeout-minutes: 30` in place of the six-hour default. ### Added +- Top-level Class and Regexp schemas now work for scalar JSON documents, using the same dispatch rules as nested values. +- `match_json_schema` exposes an RSpec description for one-line and documentation formatter output. +- Direct unit coverage for schema matching, traversal, constraints, and the Rails generators; randomized spec ordering; Ruby warnings; and a 90% SimpleCov floor. +- Contributor and security policy documents, included in the packaged gem. - A weekly `schedule` trigger, so `bundler-audit` reports an advisory published against a lockfile nobody has touched. Only the audit runs on that trigger. `workflow_dispatch` runs it on demand and is also how the cron is re-armed, since GitHub disables scheduled workflows after 60 days without repository activity. +### Removed +- The internal example interface fixture from the packaged library; it now lives under test support. +- Redundant Rails 7.1, 7.2, and 8.0 appraisal files after reducing generator compatibility checks to the supported boundaries. + ## [1.6.0] - 2026-09-03 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f56ef0c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing + +Bug reports and pull requests are welcome. + +## Setup + +Use the Ruby version declared in `.ruby-version` and Bundler 4.0.4: + +```sh +gem install bundler -v 4.0.4 +bundle install +``` + +Run the local checks before opening a pull request: + +```sh +bundle exec rspec +bundle exec rubocop +bundle exec bundle-audit check --update +``` + +Matcher changes should include focused examples under `spec/rspec/json_api`. Generator changes should include examples under `spec/generators` and be checked against both appraisal gemfiles. + +Keep pull requests focused and explain user-visible behavior changes in `CHANGELOG.md`. By participating, you agree to follow the [code of conduct](CODE_OF_CONDUCT.md). diff --git a/Gemfile b/Gemfile index 8c52f61..491ec26 100644 --- a/Gemfile +++ b/Gemfile @@ -4,12 +4,16 @@ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } -# Specify your gem's dependencies in rspec-json_api.gemspec +# Runtime dependencies come from the gemspec. gemspec -gem "activesupport", ">= 6.1.4.1" gem "bundler-audit", "~> 0.9" -gem "diffy", "~> 3.4" gem "rake", "~> 13.2" -gem "rspec-rails", ">= 5.0.2" +gem "rspec", "~> 3.13" gem "rubocop", "~> 1.65" +gem "simplecov", "~> 0.22" + +group :generators do + gem "railties", ">= 6.1.4.1" + gem "rspec-rails", ">= 5.0.2" +end diff --git a/Gemfile.lock b/Gemfile.lock index 0202cb4..28299c4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,9 @@ PATH remote: . specs: - rspec-json_api (1.6.0) - activesupport (>= 6.1.4.1) + rspec-json_api (2.0.0) diffy (>= 3.4.2) - railties (>= 6.1.4.1) - rspec-rails (>= 5.0.2) + rspec-expectations (~> 3.0) GEM remote: https://rubygems.org/ @@ -51,6 +49,7 @@ GEM crass (1.0.7) diff-lcs (1.6.2) diffy (3.4.4) + docile (1.4.1) drb (2.2.3) erb (6.0.7) erubi (1.13.1) @@ -78,7 +77,7 @@ GEM racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) - parallel (2.1.0) + parallel (1.28.0) parser (3.3.12.0) ast (~> 2.4.1) racc @@ -113,7 +112,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.4.2) - rbs (4.2.0) + rbs (4.1.3) logger prism (>= 1.6.0) tsort @@ -125,6 +124,10 @@ GEM regexp_parser (2.12.0) reline (0.7.0) io-console (~> 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) @@ -158,6 +161,12 @@ GEM prism (~> 1.7) ruby-progressbar (1.13.0) securerandom (0.4.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) thor (1.5.0) tsort (0.2.0) tzinfo (2.0.6) @@ -176,13 +185,14 @@ PLATFORMS x86_64-linux DEPENDENCIES - activesupport (>= 6.1.4.1) bundler-audit (~> 0.9) - diffy (~> 3.4) + railties (>= 6.1.4.1) rake (~> 13.2) + rspec (~> 3.13) rspec-json_api! rspec-rails (>= 5.0.2) rubocop (~> 1.65) + simplecov (~> 0.22) BUNDLED WITH 4.0.4 diff --git a/README.md b/README.md index c963508..c159995 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,168 @@ # RSpec::JsonApi -[RSpec:JsonAPI](https://github.com/nomtek/rspec-json_api) -is an extension for [RSpec](https://github.com/rspec) -to easily allow testing JSON API responses. +`rspec-json_api` adds RSpec matchers for checking JSON values against compact Ruby schemas. Despite the name, it validates general JSON response shapes; it does not implement the [JSON:API specification](https://jsonapi.org/). + +## Requirements + +- Ruby 3.2 or newer +- RSpec 3 +- Rails 6.1 or newer only when using the optional generators + +The CI suite covers matcher behavior on Ruby 3.2, 3.3, 3.4, and 4.0. Generator integration is exercised at the supported Rails boundaries: Rails 6.1 on Ruby 3.2 and Rails 8.1 on Ruby 3.4. ## Installation -Add this line to your application's Gemfile: +Add the gem to your test group: ```ruby -gem 'rspec-json_api' +group :test do + gem "rspec-json_api" +end ``` -And then execute: +Then run: - $ bundle install +```sh +bundle install +``` + +Load the matchers from `spec/spec_helper.rb` (or your equivalent RSpec setup file): -Or install it yourself as: +```ruby +require "rspec/json_api" +``` - $ gem install rspec-json_api +Rails projects can generate the definition directories: -Generate directory tree: +```sh +rails generate rspec:json_api:install +``` - rails generate rspec:json_api:install +Load custom types before interfaces in `rails_helper.rb`, because interfaces may reference types: -Require gem assets in your `rails_helper.rb` ```ruby -Dir[File.join(__dir__, 'rspec', 'json_api', 'types', '*.rb')].each { |file| require file } -Dir[File.join(__dir__, 'rspec', 'json_api', 'interfaces', '*.rb')].each { |file| require file } +Dir[File.join(__dir__, "rspec", "json_api", "types", "*.rb")].each { |file| require file } +Dir[File.join(__dir__, "rspec", "json_api", "interfaces", "*.rb")].each { |file| require file } ``` -## Generators +The matchers themselves do not depend on Rails, ActiveSupport, or `rspec-rails`. -Using build-in generators it's possible to create custom interface and type. +## Matchers -Generate new template: +### `match_json_schema` - rails generate rspec:json_api:interface interface-name +Pass the matcher a JSON String and describe the parsed value with Ruby values, classes, regular expressions, arrays, hashes, or constraint Procs: -Generate new type: +```ruby +schema = { + id: RSpec::JsonApi::Types::UUID, + name: String, + age: -> { { type: Integer, min: 18 } }, + tags: [String] +} - rails generate rspec:json_api:type type-name +expect(response.body).to match_json_schema(schema) +``` +The actual value must be a JSON String. Invalid JSON and non-String inputs fail the match. Schema keys must be symbols because JSON object keys are symbolized while parsing. -## Example usage +Object schemas are strict at every nesting level: every expected key must be present and unexpected keys fail the match. `allow_blank` permits a blank value; it does not make a key optional. + +Root schemas may describe objects, arrays, or scalar JSON values: ```ruby -# spec/controllers/users_controller_spec.rb +expect('"ready"').to match_json_schema(String) +expect('"ready"').to match_json_schema(/\Aready\z/) +expect("42").to match_json_schema(42) +``` -RSpec.describe UsersController, type: :controller do - describe '#index' do - let(:expected_schema) do - [{ - id: RSpec::JsonApi::Types::UUID, - name: String, - age: Integer, - favoriteColorHex: /\A\#([a-fA-F]|[0-9]){3,6}\z/, - number: -> { { type: Integer, min: 10, max: 20, lambda: lambda(&:even?) } } - }] - end +### `have_no_content` - it 'matches API response' do - get :index +`have_no_content` matches only an empty String: - expect(response.body).to match_json_schema(expected_schema) - end - end - - describe '#update' do - it 'matches API response' do - put :update, params: { name: 'John', age: 35 } - - expect(response.body).to have_no_content - end - end -end +```ruby +expect(response.body).to have_no_content ``` -## Built-in matchers -- ### match_json_schema -``` - expect(response.body).to match_json_schema(expected_schema) -``` +JSON objects, JSON arrays, and whitespace-only bodies are considered content. -- ### have_no_content -``` - expect(response.body).to have_no_content +## Schema Values + +### Exact values + +```ruby +schema = { status: "ready", count: 2 } ``` -## Interfaces -The gem introduces interfaces to reuse them during test matches. +### Classes + +Classes use `instance_of?`, so subclasses do not match: ```ruby -# spec/rspec/json_api/interfaces/example_interface.rb +schema = { id: Integer, name: String } +``` -module RSpec - module JsonApi - module Interfaces - EXAMPLE_INTERFACE = { - id: Types::UUID, - name: String, - number: Integer, - color: -> { { inclusion: %w[black red white], allow_blank: true } } - }.freeze - end - end -end +### Regular expressions + +A regular expression matches only a JSON String. Numbers, booleans, and `null` do not match after conversion: + +```ruby +schema = { color: /\A#[0-9a-fA-F]{6}\z/ } ``` -_Note: You can either generate file on your own or use generator._ -## Types -The gem allow users either to user build-in types or define owns. -### Build-in types -- #### EMAIL +Use `\A` and `\z` for whole-string validation. Ruby's `^` and `$` are line anchors and may accept a matching line inside a multiline value. + +### Arrays + +Array schemas have three forms: + ```ruby -RSpec::JsonApi::Types::EMAIL +[String] # any-length list of Strings +[{ id: Integer, name: String }] # any-length list of this object shape +[Integer, String] # an exact two-element tuple ``` -- #### URI + +The one-element shorthand applies only to a Class or Hash. For example, `[Types::UUID]` means an exact one-element array because `Types::UUID` is a Regexp. + +### Constraint Procs + +A constraint Proc takes no arguments and returns an options Hash: + ```ruby -RSpec::JsonApi::Types::URI +schema = { + age: -> { { type: Integer, min: 18, max: 120 } }, + role: -> { { inclusion: %w[admin member] } }, + code: -> { { regex: /\A[A-Z]{3}\z/ } }, + even: -> { { lambda: ->(value) { value.even? } } }, + nickname: -> { { type: String, allow_blank: true } } +} ``` -- #### UUID + +Supported options are `allow_blank`, `type`, `value`, `min`, `max`, `inclusion`, `regex`, and `lambda`. All supplied constraints must pass. Unknown options, a non-Hash return value, or a Proc that declares an argument raises `ArgumentError` with usage guidance. + +`allow_blank: true` accepts `null`, `false`, empty strings, whitespace-only strings, empty arrays, and empty objects. The key itself remains required. + +## Built-in Types + +The built-in types are anchored regular expressions: + ```ruby +RSpec::JsonApi::Types::EMAIL +RSpec::JsonApi::Types::URI RSpec::JsonApi::Types::UUID ``` +`URI` accepts schemes supported by Ruby's standard URI parser, not only HTTP and HTTPS. -Custom type example: -```ruby -# spec/rspec/json_api/types/color_hex.rb +Generate a custom type with Rails: + +```sh +rails generate rspec:json_api:type color_hex +``` + +Or define one directly: +```ruby module RSpec module JsonApi module Types @@ -137,184 +170,57 @@ module RSpec end end end - -RSpec::JsonApi::Types::COLOR_HEX ``` -_Note: You can either generate file on your own or use generator._ -## Matching methods -The gem offers variety of possible matching methods. +## Interfaces -### Presumptions -- `match_json_schema` always require full keys match. +Interfaces are reusable strict object schemas: - Failure Example: - ```ruby - let(:expected_schema) do - { - id: RSpec::JsonApi::Types::UUID, +```ruby +module RSpec + module JsonApi + module Interfaces + PERSON = { + id: Types::UUID, name: String, - age: Integer - } - end - - let(:actual) do - { - id: "0a2f911f-3767-4cc7-9c19-049f4350e38c", - name: "Mikel", - } + active: -> { { inclusion: [true, false] } } + }.freeze end - ``` - - Success Example: - ```ruby - let(:expected_schema) do - { - id: RSpec::JsonApi::Types::UUID, - name: String, - age: Integer - } end - - let(:actual) do - { - id: "0a2f911f-3767-4cc7-9c19-049f4350e38c", - name: "John", - age: 24 - } - end - ``` - -### Value match -```ruby -let(:expected_schema) do - { - id: "e0067346-4d24-4aa6-b303-f927a410a001", - name: "John", - age: 24, - favoriteColorHex: "#FF5733" - } end ``` -### Class match -```ruby -let(:expected_schema) do - { - id: Integer, - name: String, - age: Integer, - notes: [String] - } -end -``` +Generate one with: -### Type match -```ruby -let(:expected_schema) do - { - id: RSpec::JsonApi::Types::UUID, - email: RSpec::JsonApi::Types::EMAIL, - } -end +```sh +rails generate rspec:json_api:interface person ``` -### Regexp match -```ruby -let(:expected_schema) do - { - color: /\A\#([a-fA-F]|[0-9]){3,6}\z/ - } -end -``` -_Note: anchor with `\A` and `\z`, not `^` and `$`. `^` and `$` match at line boundaries, so `/^\#[0-9a-fA-F]{3}$/` also accepts `"not a color\n#FFF"` and the value only has to contain a matching line for the schema to pass. The built-in `EMAIL`, `URI` and `UUID` types are anchored this way._ +Use an interface directly or as a homogeneous list schema: -### Interface match ```ruby -let(:expected_schema) do - [RSpec::JsonApi::Interfaces::PERSON] -end +expect(response.body).to match_json_schema(RSpec::JsonApi::Interfaces::PERSON) +expect(response.body).to match_json_schema([RSpec::JsonApi::Interfaces::PERSON]) ``` -### Proc match -Proc match allows to customize schema according needs using lambda shorthand notation `->` +## Development -Supported options: -- #### type -```ruby -let(:expected_schema) do - { - name: -> { { type: String } } - } -end -``` -- #### value -```ruby -let(:expected_schema) do - { - name: -> { { value: "John" } } - } -end -``` -- #### min -```ruby -let(:expected_schema) do - { - age: -> { { min: 15 } } - } -end -``` -- #### max -```ruby -let(:expected_schema) do - { - age: -> { { max: 25 } } - } -end -``` -- #### inclusion -```ruby -let(:expected_schema) do - { - letter: -> { { inclusion: %w[A B C] } } - } -end -``` -- #### regex -```ruby -let(:expected_schema) do - { - hex: -> { { regex: /^\#([a-fA-F]|[0-9]){3,6}$/ } } - } -end -``` -- #### lambda -```ruby -let(:expected_schema) do - { - number: -> { { lambda: lambda(&:even?) } } - } -end -``` -- #### allow_blank +Use the Ruby version in `.ruby-version` and Bundler 4.0.4: -```ruby -let(:expected_schema) do - { - name: -> { { type: String, allow_blank: true } } - } -end +```sh +gem install bundler -v 4.0.4 +bundle install +bundle exec rspec +bundle exec rubocop +bundle exec bundle-audit check --update ``` -_Note: Default value is `false`_ - -## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/nomtek/rspec-json_api. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/nomtek/rspec-json_api/blob/master/CODE_OF_CONDUCT.md). +See [CONTRIBUTING.md](CONTRIBUTING.md) for compatibility and contribution guidance. Please report vulnerabilities using [GitHub's private security advisory form](https://github.com/nomtek/rspec-json_api/security/advisories/new), as described in [SECURITY.md](SECURITY.md). ## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +The gem is available under the terms of the [MIT License](LICENSE.txt). ## Code of Conduct -Everyone interacting in the RSpec::JsonApi project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/nomtek/rspec-json_api/blob/master/CODE_OF_CONDUCT.md). +Everyone participating in this project is expected to follow the [code of conduct](CODE_OF_CONDUCT.md). diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index d7fece3..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,522 +0,0 @@ -# Roadmap - -Deep review of `rspec-json_api` 1.5.0 (master at `87fddee`), done on 2026-09-03. - -## How this was produced - -- Read every file in the repository: library, generators, specs, gemspec, Gemfile and lockfile, appraisal gemfiles, CI workflow, RuboCop config, README and CHANGELOG. -- Ran the suite and linter on Ruby 4.0.1 with the committed `Gemfile.lock`: 63 examples, 0 failures; RuboCop 29 files, no offenses. -- Ran `bundle outdated` and `bundle-audit check --update` against `Gemfile.lock`. -- Ran a throwaway probe script that feeds edge-case schemas through `RSpec::JsonApi::Matchers::MatchJsonSchema`. Every "Confirmed" bug below quotes the exact input, so it can be reproduced in `bin/console`. - -Each item is marked either **Confirmed** (reproduced or directly visible in the code) or **Suggestion** (a judgement call, an assumption, or a design proposal). Priorities follow the impact on a consumer's test suite: Critical means a valid test run crashes or a wrong response passes; High means correctness or supply-chain problems that are cheap to fix; Medium means real friction; Low means polish. - -## Top ten by priority and impact - -| # | Item | Priority | Effort | Status | -|---|------|----------|--------|--------| -| 1.1 | List schemas crash with `NoMethodError` when the actual value is not an array | Critical | S | Confirmed | -| 1.2 | Elements of exact arrays skip the key-structure guard (false positives) | High | S | Confirmed | -| 1.3 | `Types::URI` is unanchored, so a URI buried in prose passes | High | S | Confirmed | -| 1.4 | Matcher raises `TypeError` for `nil` or already-parsed input | High | S | Confirmed | -| 2.1 | Drop `railties` and `rspec-rails` from the runtime dependencies | High | M | Confirmed | -| 2.3 | Move the dev lockfile past 70+ open advisories | High | S | Confirmed | -| 3.1 | Report the failing key path instead of a one-line `inspect` diff | High | L | Suggestion | -| 5.1 | Close the spec gaps that let the crash paths ship | High | M | Confirmed | -| 4.1 | Optional keys and nullable values in the schema DSL | High | M | Suggestion | -| 5.2 | CI matrix exercises almost no Rails code | Medium | M | Confirmed | - -## Progress - -Phase 1 is implemented on branch `fix/roadmap-phase-1`, released as 1.6.0 rather than 1.5.1 because code review pulled item 3.2 forward and that adds a capability. Done: 1.1, 1.3, 1.4, 1.6, 1.7, 2.3, 2.4, 3.2, 5.3, 5.5, each marked on its heading below. Nothing has been published to RubyGems. - -Review of that work also turned up two crashes the original audit missed, both now fixed and specced. `SchemaMatch.compare` raised `NoMethodError` when an object schema met array elements of another shape, which is item 1.1's bug on the object path rather than the array path. And the guard added for item 1.6 read a Proc's arity, which does not catch `proc { |value| ... }`, because Ruby reports a non-lambda block parameter as optional; the check now reads the parameter list. - -## 1. Bugs to fix - -### 1.1 List schemas crash when the actual value is not an array (done in 1.6.0) - -Priority: Critical. Category: Correctness. Effort: S. Status: Confirmed. - -**Evidence.** `lib/rspec/json_api/schema_match.rb:97-111`. `compare_typed_array` and `compare_interface_array` call `actual_value.all?` without checking the receiver. Reproduced against 1.5.0: - -```ruby -match_json_schema({ notes: [String] }).matches?('{"notes":"x"}') # NoMethodError: undefined method 'all?' for String -match_json_schema({ notes: [String] }).matches?('{"notes":null}') # NoMethodError: undefined method 'all?' for nil -match_json_schema({ items: [{ id: Integer }] }).matches?('{"items":null}') # NoMethodError -match_json_schema({ items: [{ tags: [String] }] }).matches?('{"items":[{"tags":"b"}]}') # NoMethodError -``` - -**Problem and impact.** An API that returns `null` or a scalar where a list is expected is the everyday failure this gem exists to catch. Instead of a red example with a message, the consumer's suite errors out. Nested cases (probe 4) crash from inside `compare_interface_array`, so interface arrays are affected too. The 1.5.0 fix for `dig` raising `TypeError` on scalars (commit `c30eb5f`) covered objects but left the array branches with the same class of bug. - -**Recommended solution.** Guard at the top of `compare_array`: `return false unless actual_value.is_a?(Array)`. Add one spec per crash input above. This also makes probe 4 fail cleanly instead of raising. - -**Dependencies and risks.** None. Pure fix. - -### 1.2 Elements of exact arrays skip the key-structure guard - -Priority: High. Category: Correctness. Effort: S. Status: Confirmed. - -**Evidence.** `lib/rspec/json_api/schema_match.rb:114-120`. `compare_exact_array` routes Hash elements through `compare`, not `match`, so `same_key_structure?` never runs for them. `compare` unions the key paths of both sides and compares value by value, and `nil == nil` is true. Reproduced: - -```ruby -# extra null-valued key in an element: passes -match_json_schema({ c: [{ id: Integer }, { id: Integer }] }) - .matches?('{"c":[{"id":1,"x":null},{"id":2}]}') # => true - -# missing key whose schema allows blank: passes inside the array... -match_json_schema({ c: [{ id: Integer, n: -> { { type: String, allow_blank: true } } }, { id: Integer }] }) - .matches?('{"c":[{"id":1},{"id":2}]}') # => true - -# ...but the same shape is rejected at the top level -match_json_schema({ n: -> { { type: String, allow_blank: true } } }).matches?('{}') # => false -``` - -**Problem and impact.** The README promises "match_json_schema always require full keys match". Inside an exact array that promise does not hold: a response with unexpected null fields, or with fields missing, passes. Commit `a79e487` fixed exactly this for interface arrays in 1.5.0 and left the exact-array branch untouched. - -**Recommended solution.** In `compare_exact_array`, call `match(actual_value[index], elem)` for Hash elements (as `compare_interface_array` already does). Add the two inputs above as failing specs first. - -**Dependencies and risks.** Behaviour change: suites that relied on the lax check will start failing, correctly. Note it under "Fixed" in the CHANGELOG. Pairs naturally with 3.2, which touches the same method. - -### 1.3 `Types::URI` is unanchored (done in 1.6.0) - -Priority: High. Category: Correctness. Effort: S. Status: Confirmed. - -**Evidence.** `lib/rspec/json_api/types/uri.rb:6` uses `URI::DEFAULT_PARSER.make_regexp`, which has no `\A`/`\z` anchors. `compare_regexp` (`schema_match.rb:78-80`) uses `match?`, so any substring match wins: - -```ruby -match_json_schema({ u: RSpec::JsonApi::Types::URI }) - .matches?('{"u":"not a uri but see http://example.com ok"}') # => true -``` - -`Types::EMAIL` (`URI::MailTo::EMAIL_REGEXP`) is anchored and rejects the equivalent input, and `Types::UUID` was anchored in 1.5.0 (commit `ba59f62`) for the same reason. URI was missed. - -**Problem and impact.** A field that should hold a URL accepts free text as long as a URL appears somewhere in it. For a type that ships as a built-in, that is a silent correctness hole. - -**Recommended solution.** `URI = /\A#{URI::DEFAULT_PARSER.make_regexp}\z/`. Add a spec with a URI embedded in prose and one with leading whitespace. Consider a stricter `Types::URL` (http/https only) as a separate built-in, since `make_regexp` also accepts `mailto:` and `urn:` (see 4.4). - -**Dependencies and risks.** Strings with surrounding whitespace start failing; that is the intended behaviour. - -### 1.4 Matcher raises `TypeError` for `nil` or already-parsed input (done in 1.6.0) - -Priority: High. Category: Robustness. Effort: S. Status: Confirmed. - -**Evidence.** `lib/rspec/json_api/matchers/match_json_schema.rb:25-33` rescues only `JSON::ParserError`. `JSON.parse(nil)` and `JSON.parse({})` raise `TypeError`: - -```ruby -match_json_schema({ id: String }).matches?(nil) # TypeError: no implicit conversion of nil into String -match_json_schema({ id: String }).matches?({ id: "x" }) # TypeError: no implicit conversion of Hash into String -``` - -**Problem and impact.** `expect(nil).to match_json_schema(...)` is a plausible outcome of a helper that returns `nil`, and passing `JSON.parse(response.body)` or `response.parsed_body` is what request specs naturally hand over. Both crash instead of failing with a message. - -**Recommended solution.** Short term: rescue `TypeError` alongside `JSON::ParserError` and set a `@parse_error` that `failure_message` prints ("expected a JSON String, got NilClass"). Longer term: accept Hash/Array input directly and objects that respond to `body` (see 4.3). - -**Dependencies and risks.** None for the rescue. Accepting parsed input is a feature decision (4.3). - -### 1.5 Regexp schema values ignore the value's type - -Priority: Medium. Category: Correctness. Effort: S. Status: Confirmed. - -**Evidence.** `lib/rspec/json_api/schema_match.rb:78-80` and `constraints.rb:43` call `to_s` on the actual value before matching: - -```ruby -match_json_schema({ code: /\A\d+\z/ }).matches?('{"code":123}') # => true (an Integer) -match_json_schema({ code: /.*/ }).matches?('{"code":null}') # => true (nil.to_s == "") -``` - -**Problem and impact.** A regexp schema is the documented way to say "a string shaped like X". A number or `null` should not satisfy it. The `null` case is worse: a permissive regex accepts a missing value, which is what `allow_blank` exists to express explicitly. - -**Recommended solution.** `actual_value.is_a?(String) && expected_value.match?(actual_value)` in both places. Document that regexp schemas imply String. - -**Dependencies and risks.** Breaking for suites that regex-match numbers. Ship with 2.1 in a version that already carries a CHANGELOG "Changed" section; consider 2.0.0 for the combined set (see phasing). - -### 1.6 Misused Proc schemas raise raw Ruby errors (done in 1.6.0) - -Priority: Medium. Category: Robustness. Effort: S. Status: Confirmed. - -**Evidence.** `lib/rspec/json_api/constraints.rb:31-36` assumes the Proc returned a Hash; `schema_match.rb:82-84` calls the Proc with no arguments: - -```ruby -match_json_schema({ a: -> { true } }).matches?('{"a":1}') # NoMethodError: undefined method 'keys' for true -match_json_schema({ a: ->(v) { v > 1 } }).matches?('{"a":2}') # ArgumentError: wrong number of arguments (given 0, expected 1) -``` - -**Problem and impact.** Both shapes are what a first-time user reaches for. The README shows the correct form (`-> { { lambda: ... } }`) but the error a user gets does not point there. 1.5.0 added `ArgumentError` for unknown option keys; the non-Hash and arity cases were not covered. - -**Recommended solution.** In `validate!`, raise `ArgumentError, "schema Proc must return an options Hash, got TrueClass"` when the result is not a Hash. In `compare_proc`, either raise a clear error for arity 1, or treat an arity-1 lambda as a predicate (`condition.call(value)`), which is the more useful behaviour and a small feature. - -**Dependencies and risks.** If arity-1 lambdas become predicates, document it and add specs; it overlaps with 4.2. - -### 1.7 `have_no_content_spec.rb` never tests the `"{}"` case (done in 1.6.0) - -Priority: Low. Category: Test correctness. Effort: S. Status: Confirmed. - -**Evidence.** `spec/rspec/json_api/matchers/have_no_content_spec.rb:12-20` calls `let(:actual)` twice inside one context from a `%w[{} []].each` loop. The second `let` overrides the first, so both examples run with `"[]"`. Verified by printing `actual` from a copy of the block: both print `"[]"`. The `describe` string "match_empty_body matcher" is also stale; the matcher is `have_no_content`. - -**Problem and impact.** One of the two negative cases is untested and the documentation-format output shows two identically named examples. - -**Recommended solution.** Wrap each value in its own `context "when #{value} is given"`, and rename the top-level describe. - -### 1.8 `same_key_structure?` cannot tell which parent a nested hash belongs to - -Priority: Low. Category: Correctness (latent). Effort: S. Status: Suggestion. - -**Evidence.** `lib/rspec/json_api/traversal.rb:15-20` flattens nested keys into the parent's list (`{a: {c: 1}, b: 2}` becomes `[:a, [:c], :b]`) and `deep_sort` (`traversal.rb:42-46`) sorts by string, so parent association is lost: - -```ruby -RSpec::JsonApi::SchemaMatch.same_key_structure?({ a: 1, b: { c: "x" } }, { a: { c: String }, b: Integer }) # => true -``` - -**Problem and impact.** I could not turn this into an end-to-end false positive, because `compare` re-derives full key paths and catches the mismatch. But the guard is weaker than its name and comment claim, and a future refactor of `compare` could expose it. - -**Recommended solution.** Compare sorted `deep_key_paths` of both sides instead of `deep_sort(deep_keys(...))`. That removes `deep_keys` and `deep_sort` entirely. Folds into 3.6. - -## 2. Package and dependency updates - -### 2.1 Drop `railties` and `rspec-rails` from the runtime dependencies - -Priority: High. Category: Dependencies / supply chain. Effort: M. Status: Confirmed. - -**Evidence.** `rspec-json_api.gemspec:35-38` declares `activesupport`, `diffy`, `railties` and `rspec-rails` as runtime dependencies. `grep -rn "rspec\|Rails" lib` shows that nothing in `lib/rspec/` references `rspec-rails` or Rails; the only Rails references are the three generator classes under `lib/generators/`, and those are only ever loaded by Rails itself (it scans `lib/generators` of bundled gems). The matcher module (`lib/rspec/json_api/matchers.rb`) only needs `RSpec::Matchers` from `rspec-expectations`. - -**Problem and impact.** Every consumer pulls `actionpack`, `actionview`, `rack`, `rack-session`, `nokogiri`, `loofah`, `rails-html-sanitizer`, `crass`, `irb`, `rackup` and friends into their bundle to get a JSON matcher. In the current dev lockfile those transitive gems account for every one of the 70+ advisories `bundle-audit` reports (see 2.3). It also means a non-Rails project (Sinatra, Hanami, plain Rack) cannot use the gem without taking on `railties`. 1.5.0 already went from `rails` to `railties` (87 to 65 gems); this is the second step. - -**Recommended solution.** -- Runtime: `activesupport` (until 2.2 lands), `diffy`, `rspec-expectations ~> 3.0`. -- Development: `railties`, `rspec-rails`, `rake`, `rubocop`, plus `bundler-audit` and `simplecov` (5.1, 5.3). -- Keep `lib/generators/**` where it is. Rails finds it when both the gem and Rails are in the bundle; without Rails the files are never required. Add a comment in each generator saying so. -- README: state that the generators need Rails, the matchers do not. - -**Dependencies and risks.** A consumer who never listed `rspec-rails` in their own Gemfile and relied on this gem pulling it in would lose it. That is unlikely (rspec-rails is what people install first) but call it out in the CHANGELOG and bump to 2.0.0 together with 1.5 and 2.2. - -### 2.2 Replace the ActiveSupport `blank?`/`present?` extension with a local helper - -Priority: Medium. Category: Dependencies. Effort: S. Status: Confirmed (usage), Suggestion (removal). - -**Evidence.** `lib/rspec/json_api.rb:7` requires `active_support/core_ext/object/blank`. It is used in exactly two places: `constraints.rb:24` (`value.blank?`) and `schema_match.rb:36` (`actual.blank? && expected.present?`). - -**Problem and impact.** Two call sites carry `activesupport` plus its own tail (`concurrent-ruby`, `i18n`, `tzinfo`, `minitest`, `drb`, `bigdecimal`, `logger`, `securerandom`, ...). After 2.1 this would be the last heavyweight dependency; removing it leaves `diffy` and `rspec-expectations`. - -**Recommended solution.** A private `RSpec::JsonApi::Blank.blank?(value)` that reproduces the semantics that matter here: `nil`, `false`, empty String/Array/Hash, and whitespace-only strings. Write the specs first (the whitespace case is what `allow_blank` users depend on), then swap the two call sites. The `schema_match.rb:36` line also deserves a second look: with `same_key_structure?` already enforced by `match`, it only fires for the exact-array path, and after 1.2 it can probably go. - -**Dependencies and risks.** Requires 2.1 to be worthwhile. `blank?` on unusual objects (e.g. `BigDecimal`) is not relevant because input always comes from `JSON.parse`. - -### 2.3 Move the development lockfile past open security advisories (done in 1.6.0) - -Priority: High. Category: Security / dependencies. Effort: S. Status: Confirmed. - -**Evidence.** `bundle-audit check --update` on `Gemfile.lock` (2026-09-03). Grouped by gem, with the fix version the advisory database names: - -| Gem | Locked | Advisories | Fix | -|-----|--------|------------|-----| -| rack | 3.2.4 | 14 (five rated High) | >= 3.2.6 | -| nokogiri | 1.19.0 | 13 (one High) | >= 1.19.4 | -| activesupport | 8.1.2 | 3 (ReDoS, XSS, DoS in helpers) | >= 8.1.2.1 | -| actionpack / actionview | 8.1.2 | 1 each (XSS) | >= 8.1.2.1 | -| concurrent-ruby | 1.3.6 | 3 (one High) | >= 1.3.7 | -| loofah | 2.25.0 | 4 | >= 2.25.2 | -| crass | 1.0.6 | 4 | >= 1.0.7 | -| erb | 6.0.1 | 1 (High, deserialization guard bypass) | >= 6.0.4 | -| json | 2.18.0 | 2 | >= 2.19.9 | -| rails-html-sanitizer | 1.6.2 | 1 (XSS) | >= 1.7.1 | -| rack-session | 2.1.1 | 1 (session forgery) | >= 2.1.2 | - -`bundle outdated` shows `railties 8.1.3.1` is available, which drags the Rails 8.1.2.1+ fixes in. - -**Problem and impact.** None of these gems are used by the matcher at runtime and the lockfile does not bind consumers, so the direct exposure is the CI runner and contributor machines. Two things still make it worth doing now: `Gemfile.lock` is packaged inside the `.gem` (5.5), so a scanner pointed at the published artifact flags it, and the list is the concrete argument for 2.1. - -**Recommended solution.** `bundle update --conservative rack nokogiri railties concurrent-ruby loofah crass erb json rails-html-sanitizer rack-session`, run the suite, commit the lockfile. Then add `bundle-audit` to CI (5.3) so the list does not grow back quietly. - -**Dependencies and risks.** Low. All are patch-level within the ranges the Gemfile allows. - -### 2.4 Routine minor updates (done in 1.6.0) - -Priority: Low. Category: Dependencies. Effort: S. Status: Confirmed. - -**Evidence.** `bundle outdated`: rubocop 1.82.1 to 1.90.0 (allowed by `~> 1.65`), rake 13.3.1 to 13.4.2, rspec-rails 8.0.2 to 8.0.4, zeitwerk 2.7.4 to 2.8.3, i18n 1.14.8 to 1.15.2, rdoc 7.1.0 to 8.0.0. Two transitive majors are available and can wait: `parallel` 2.1.0 (via rubocop) and `diff-lcs` 2.0.0 (via rspec). - -**Recommended solution.** Fold into the same PR as 2.3. Newer RuboCop versions add cops under `NewCops: enable` (`.rubocop.yml:10`), so expect a few new offences to address. - -### 2.5 `Gemfile` duplicates gemspec dependencies with divergent constraints - -Priority: Low. Category: Dependencies / hygiene. Effort: S. Status: Confirmed. - -**Evidence.** `Gemfile:10-13` re-declares `activesupport`, `diffy` and `rspec-rails` that `gemspec` (line 8) already brings in, and `diffy` is `"~> 3.4"` in the Gemfile versus `">= 3.4.2"` in the gemspec. - -**Problem and impact.** Two places to keep in sync; the gemspec is the one consumers see. The appraisal gemfiles inherit the root Gemfile (`gemfiles/*.gemfile:5`), so the duplication propagates to the CI matrix. - -**Recommended solution.** Keep only `gemspec`, `rake`, `rubocop` (and the new dev tools) in the Gemfile; put the rest in `add_development_dependency` after 2.1. - -## 3. Code quality and architecture improvements - -### 3.1 Report the failing key path instead of a one-line `inspect` diff - -Priority: High. Category: Architecture / developer experience. Effort: L. Status: Suggestion. - -**Evidence.** `SchemaMatch.match` (`schema_match.rb:15-28`) returns a bare Boolean with no context. `MatchJsonSchema#failure_message` (`match_json_schema.rb:37-45`) prints `expected` and `actual` with `Hash#to_s` and hands the same two strings to Diffy (`match_json_schema.rb:58-60`). Actual output for a schema with one Proc and one type mismatch: - -``` -expected: {id: Integer, name: String, tags: [String], nested: {x: #}} - got: {id: "1", name: "n", tags: ["a"], nested: {x: 0}} - -Diff: --{id: Integer, name: String, tags: [String], nested: {x: #}} -\ No newline at end of file -+{id: "1", name: "n", tags: ["a"], nested: {x: 0}} -\ No newline at end of file -``` - -**Problem and impact.** For a 40-key response the user gets two very long lines, a Proc address with a local file path, and no indication of which key failed or why (wrong type, extra key, missing key, constraint). The diff is a full-line replace, so Diffy adds nothing over the two lines above it. Diffy also shells out to `diff(1)` per failure. This is the single biggest day-to-day cost of using the gem. - -**Recommended solution.** Make `SchemaMatch` a single recursive walk that collects `Mismatch` records (path, reason, expected, actual) instead of returning `false` at the first miss. Render them as `at $.children[1].age: expected Integer, got String ("x")` and `at $.children[0]: unexpected key "x"`. Render schema values by name (`Integer`, `/\A\d+\z/`, `Proc(type: String, min: 1)`) and pretty-print the actual JSON. Diffy then becomes optional or goes away. This walk also replaces `same_key_structure?` plus `deep_key_paths` plus root-relative `dig_path` (see 5.7 and 3.6). - -**Dependencies and risks.** Largest item in the roadmap. Do it after 1.1, 1.2 and 3.2 have specs, so the rewrite has a safety net. It changes the failure text; that is not a public API, but mention it. - -### 3.2 `compare_exact_array` does not dispatch element schemas (done in 1.6.0) - -Priority: Medium. Category: Correctness / consistency. Effort: S. Status: Confirmed. - -**Evidence.** `schema_match.rb:118`: `elem.is_a?(Hash) ? compare(...) : compare_simple_value(...)`. Classes, Regexps, Procs and nested arrays as array elements are compared with `==`: - -```ruby -match_json_schema({ pair: [Integer, Integer] }).matches?('{"pair":[1,2]}') # => false -match_json_schema({ m: [[Integer]] }).matches?('{"m":[[1,2],[3]]}') # => false -``` - -**Problem and impact.** Fixed-length tuples and lists of lists cannot be expressed at all, and the failure is silent: the schema looks valid and simply never matches. - -**Recommended solution.** Dispatch every element through `compare_values` (and Hash elements through `match`, per 1.2). Document tuples and nested lists in the README once they work. - -**Dependencies and risks.** Same method as 1.2; do both in one change. - -### 3.3 Array schema dispatch is positional and ambiguous - -Priority: Medium. Category: Design. Effort: M. Status: Suggestion. - -**Evidence.** `schema_match.rb:86-94, 126-132`. `[X]` means "list of X" when `X` is a Class, "list of interface" when `X` is a Hash, and "exactly one element equal to X" otherwise. `[String, NilClass]` therefore means a two-element tuple, not a union. The README only documents the `[Class]` and `[INTERFACE]` forms. - -**Problem and impact.** Users cannot say "a list of UUID-typed strings" (`[Types::UUID]` is a one-element exact array containing a Regexp) or "a list of procs". Every new feature in section 4 will make the positional rules harder to explain. - -**Recommended solution.** Keep the two shorthand forms for compatibility and add explicit helpers on `RSpec::JsonApi` (or a `Schema` module users can include): `array_of(schema)`, `tuple(...)`, `one_of(...)`, `optional(schema)`, `nullable(schema)`. Internally represent them as small value objects that `compare_values` dispatches on. This is the natural place to hang 4.1, 4.2 and 4.8. - -**Dependencies and risks.** Design decision; agree the DSL before 3.1 so the mismatch renderer knows about the new node types. - -### 3.4 Top-level scalar, Regexp and Class schemas always fail - -Priority: Medium. Category: Correctness. Effort: S. Status: Confirmed. - -**Evidence.** `schema_match.rb:16`: `return false unless actual.instance_of?(expected.class)`. For `expected = String` the class is `Class`, so a String body never matches: - -```ruby -match_json_schema(String).matches?('"hello"') # => false -match_json_schema(/\Ahello\z/).matches?('"hello"') # => false -``` - -**Problem and impact.** Endpoints that return a bare string, number or boolean cannot be matched at all, and the reason is not obvious. - -**Recommended solution.** Route non-Hash, non-Array roots through `compare_values`; keep the `instance_of?` guard for Hash and Array roots only. - -### 3.5 Strictness rules differ by nesting level - -Priority: Low. Category: Consistency / documentation. Effort: S. Status: Confirmed. - -**Evidence.** Probe outputs in 1.2: `allow_blank` accepts `null` but not a missing key at the top level, while inside an exact array a missing key passes. Interface arrays behave like the top level. - -**Recommended solution.** After 1.2 the behaviour is uniform (strict everywhere). Write the rule down in the README: "every key in the schema must be present; `allow_blank` accepts `null` or empty, not absence; use `optional` (4.1) for absence." - -### 3.6 `Traversal` can be a single recursive helper - -Priority: Low. Category: Simplification. Effort: S. Status: Suggestion. - -**Evidence.** `traversal.rb:15-46`. `deep_keys` recurses with `respond_to?(:keys)` while `deep_key_paths` uses an explicit stack with `is_a?(Hash)` and a final `reverse`; `deep_sort` exists only to normalise `deep_keys` output. - -**Recommended solution.** One `each_path(hash) { |path, value| }` enumerator covers both uses (and fixes 1.8). If 3.1 lands, the module disappears altogether. - -### 3.7 `example_interface.rb` ships in the gem but is a test fixture - -Priority: Low. Category: Packaging / hygiene. Effort: S. Status: Confirmed. - -**Evidence.** `lib/rspec/json_api/interfaces/example_interface.rb` is not required by `lib/rspec/json_api.rb`; the only consumer is `spec/rspec/json_api/matchers/match_json_schema_spec.rb:3`. It is included in the built gem (verified by listing `rspec-json_api-1.5.0.gem`). - -**Recommended solution.** Move it to `spec/support/example_interface.rb`. The README example that mirrors it can stay as a code block. - -### 3.8 Generator namespace and `class_path` handling - -Priority: Low. Category: Maintainability. Effort: S. Status: Confirmed. - -**Evidence.** Generators live in `Rspec::JsonApi::Generators` (`lib/generators/**/*_generator.rb:3-5`), while the library is `RSpec::JsonApi`. This is deliberate: Thor derives the CLI namespace by snake-casing the constant, and `RSpec` would become `r_spec:json_api:install`. Nothing in the code says so. Separately, `InterfaceGenerator` and `TypeGenerator` interpolate only `file_name` (`interface_generator.rb:10,16`, `type_generator.rb:10,16`), so `rails g rspec:json_api:interface admin/user` writes `interfaces/user.rb` and a constant `USER`, silently dropping the namespace. - -**Recommended solution.** Add a two-line comment explaining the `Rspec` spelling. Either honour `class_path` in the output path and constant, or reject namespaced names with a clear message. Both need the generator specs from 5.1. - -### 3.9 `MatchJsonSchema` lacks `description` - -Priority: Low. Category: RSpec integration. Effort: S. Status: Confirmed. - -**Evidence.** Probe: `matcher.respond_to?(:description)` is false. RSpec's one-liner syntax (`it { is_expected.to match_json_schema(SCHEMA) }`) and `--format documentation` then fall back to a generic phrase. `failure_message_when_negated` (`match_json_schema.rb:50-52`) also has a doc comment that describes `self` as the return value, which is wrong. - -**Recommended solution.** Add `description` ("match JSON schema") and consider `RSpec::Matchers::Composable` so the matcher can be nested in `include` and `all`. Fix the comment. - -## 4. Feature proposals - -All items in this section are suggestions. - -### 4.1 Optional keys and nullable values - -Priority: High. Category: Schema DSL. Effort: M. - -**Evidence.** Today the only relaxation is `allow_blank` (`constraints.rb:24`), which accepts `null` or `""` but still requires the key to exist at the top level (probe in 1.2). There is no way to say "this key may be absent". - -**Problem and impact.** Paginated and conditional responses (`next_page` only on some pages, `deleted_at` only for deleted rows) force users to write two schemas or to loosen the whole response. - -**Recommended solution.** `optional(schema)` and `nullable(schema)` helpers (3.3), plus `optional: true` as a Proc option for people who prefer the Hash style. `optional` keys are excluded from the key-structure check when absent. - -**Dependencies and risks.** Needs 3.3 for the node types and 3.1 to report "missing required key" versus "unexpected key". - -### 4.2 Union types, a Boolean type, and `is_a?` semantics - -Priority: High. Category: Schema DSL. Effort: M. - -**Evidence.** JSON booleans have no single Ruby class, so the only ways to check one today are `inclusion: [true, false]` or a lambda (probe: both work but are undocumented). `type:` uses `instance_of?` (`constraints.rb:40`), so `type: Numeric` rejects `1` and `type: Float` rejects `1` even though JSON does not distinguish `1` from `1.0`. - -**Recommended solution.** `Types::BOOLEAN`, `one_of(Integer, Float)`, and `type:` accepting an Array of classes; switch the class check to `is_a?` so `Numeric` works (document it as a behaviour change). Also accept an arity-1 lambda directly as a predicate (1.6). - -### 4.3 Accept parsed JSON and response objects - -Priority: Medium. Category: Ergonomics. Effort: S. - -**Evidence.** `matches?` (`match_json_schema.rb:27`) only accepts a JSON String; a Hash raises (1.4). Request specs commonly have `response.parsed_body` or `JSON.parse(response.body)` at hand, and `have_no_content` (`have_no_content.rb:17`) has the same limitation. - -**Recommended solution.** If `actual` is a Hash or Array, deep-symbolise and use it; if it responds to `body`, call it; otherwise parse. Same for `have_no_content`. - -### 4.4 More built-in types - -Priority: Medium. Category: Types. Effort: S. - -**Evidence.** `lib/rspec/json_api/types/` has EMAIL, URI and UUID. `Types::URI` accepts any scheme (`mailto:`, `urn:`). - -**Recommended solution.** `Types::URL` (http/https), `Types::ISO8601_DATE`, `Types::ISO8601_DATETIME`, `Types::INTEGER_STRING`. Implement date types as lambdas around `Date.iso8601`/`Time.iso8601` rather than regexps so `2026-02-30` is rejected. - -### 4.5 Load user-defined types and interfaces automatically - -Priority: Medium. Category: Ergonomics. Effort: S. - -**Evidence.** `README.md:27-31` asks every user to paste two `Dir[...]` `require` loops into `rails_helper.rb`; the install generator (`install_generator.rb:9-11`) creates the directories but not the loader. - -**Recommended solution.** `RSpec::JsonApi.load_definitions(root = "spec/rspec/json_api")` that requires `types/*.rb` then `interfaces/*.rb` (interfaces reference types, so order matters), and have the install generator append the one-liner to `rails_helper.rb`. - -### 4.6 String keys in schemas - -Priority: Low. Category: Ergonomics. Effort: S. - -**Evidence.** `matches?` parses with `symbolize_names: true` (`match_json_schema.rb:27`), so `{ "id" => String }` never matches (probe). The README does not say keys must be symbols. - -**Recommended solution.** Deep-symbolise the schema keys once in `initialize`, or document the rule. Symbolising is friendlier and cheap. - -### 4.7 `have_no_content` failure message should show the body - -Priority: Low. Category: Developer experience. Effort: S. - -**Evidence.** `have_no_content.rb:26-37`: both messages are fixed strings; the user has to add a `puts` to see what came back. - -**Recommended solution.** Include a truncated `actual.inspect`, and accept response objects (4.3). - -### 4.8 Size constraints for lists - -Priority: Low. Category: Schema DSL. Effort: S. - -**Evidence.** `[String]` accepts `[]` (probe) and there is no way to require at least one element without a lambda. - -**Recommended solution.** `array_of(String, min: 1, max: 50)` on the 3.3 helpers. - -## 5. Other findings - -### 5.1 Testing gaps - -Priority: High. Category: Test debt. Effort: M. Status: Confirmed. - -**Evidence.** -- Every crash and false positive in section 1 lacks a spec; that is how they survived the 1.5.0 refactor. -- `Traversal` and `SchemaMatch` have no direct specs; they are exercised only through the matcher. -- `Constraints` has four examples (`constraints_spec.rb`); `inclusion`, `regex`, `lambda` and `max` are covered only via the matcher, and non-numeric `min`/`max` and Proc misuse are not covered at all. -- The three generators have zero tests. -- `match_json_schema_spec.rb` is 1,128 lines of nested `let` fixtures with 52 `include_examples` and 4 plain `it`s; adding a case means copying a 30-line block. -- `spec_helper.rb` does not enable `config.order = :random` or `config.warnings = true`. -- No coverage tooling. - -**Recommended solution.** Add specs for each section 1 input as the first commit of each fix. Add unit specs for `Constraints` and `SchemaMatch` with a table-driven style (`[schema, json, expected_result]` rows). Add generator specs with `Rails::Generators::TestCase` or the `ammeter` gem under the Rails appraisal jobs. Add SimpleCov with a floor. Turn on random ordering. - -### 5.2 The CI compatibility matrix exercises almost no Rails code - -Priority: Medium. Category: CI. Effort: M. Status: Confirmed. - -**Evidence.** `.github/workflows/main.yml:47-52` runs six Ruby/Rails pairs, but `spec/spec_helper.rb` requires only `rspec/json_api`, which in turn requires a single ActiveSupport file (`json_api.rb:7`). Nothing requires `rails` or `rspec-rails`, and the generators are never invoked. Each Rails job therefore tests `Object#blank?` against that Rails version. The 1.5.0 CHANGELOG describes the matrix as making "the advertised version support actually tested". - -**Problem and impact.** Six jobs of CI time for one line of coverage, and a false sense that Rails 6.1 through 8.1 compatibility is verified. Ruby 4.0 is also absent even though 1.5.0 shipped a Ruby 4.0 load fix (commit `8f7318a`), and the local lockfile was resolved on Ruby 4.0. - -**Recommended solution.** After 2.1, the runtime matrix only needs Ruby versions (3.2, 3.3, 3.4, 4.0) with the plain Gemfile. Keep one or two Rails appraisals that actually load `rspec-rails` and run the generator specs from 5.1 against a minimal dummy app. The `permissions: contents: read` and `concurrency` parts of this item are done; only the matrix rework is outstanding. - -### 5.3 Supply-chain checks are not automated (done in 1.6.0) - -Priority: Medium. Category: Security / DevOps. Effort: S. Status: Confirmed. - -**Evidence.** No `.github/dependabot.yml`; no `bundler-audit` step in the workflow; the workflow has no `permissions:` block (`main.yml`); `actions/checkout@v4` where v5 is current. - -**Recommended solution.** Dependabot for `bundler` and `github-actions` (weekly), a `bundle-audit check --update` job, least-privilege `permissions`, and the checkout bump. `rubygems_mfa_required` is already set in the gemspec (line 19), which is good. - -### 5.4 Release process - -Priority: Medium. Category: DevOps. Effort: M. Status: Confirmed. - -**Evidence.** Tags exist for v1.0.0, v1.0.1, v1.0.2, v1.1.0, v1.1.1 and v1.5.0 only; 1.2.x, 1.3.x and 1.4.0 were released without tags. `CHANGELOG.md` has entries for 1.5.0, 1.4.0 and 0.1.0 and nothing in between. Releases are manual (`rake release` from `bundler/gem_tasks`); a built `rspec-json_api-1.5.0.gem` sits in the working tree (gitignored). - -**Recommended solution.** A release workflow using RubyGems Trusted Publishing triggered by a `v*` tag, so publishing requires a tag and a green build. Backfill the missing tags from the version-bump commits and write short CHANGELOG entries from `git log` for 1.1 to 1.3.1. - -### 5.5 The gem packages repository tooling (done in 1.6.0) - -Priority: Low. Category: Packaging. Effort: S. Status: Confirmed. - -**Evidence.** `rspec-json_api.gemspec:23-25` includes every tracked file except `test/`, `spec/` and `features/`. Listing the built gem shows `.github/workflows/main.yml`, `.rubocop.yml`, `.gitattributes`, `.gitignore`, `.rspec`, `.ruby-version`, `Gemfile`, `Gemfile.lock`, `Rakefile`, `bin/console`, `bin/setup` and `gemfiles/*.gemfile` inside it. - -**Recommended solution.** `spec.files = Dir["lib/**/*"] + %w[LICENSE.txt README.md CHANGELOG.md]`. Drop `spec.bindir`/`spec.executables` (no `exe/` directory exists). - -### 5.6 Documentation - -Priority: Medium. Category: Documentation. Effort: S. Status: Confirmed. - -**Evidence.** `README.md`: -- Typos and grammar: "build-in" (lines 35, 114), "The gem allow users either to user build-in types or define owns" (line 113), "Proc match allows to customize schema according needs" (line 239), "The gem offers variety of possible matching methods" (line 146). -- Behaviours that are undocumented: invalid JSON yields a failed match (not an error); unknown Proc options raise `ArgumentError`; schema keys must be symbols; `type:` uses `instance_of?`; `[X]` semantics and the lack of tuples; top-level must be an object or array; how `allow_blank` interacts with missing keys. -- No supported Ruby/Rails matrix, even though the gemspec floor (Ruby 3.2, Rails 6.1.4.1) and the CI matrix define one. -- The name suggests the JSON:API specification (jsonapi.org); the gem is a general JSON-shape matcher. One sentence at the top would save readers a wrong assumption. -- No `CONTRIBUTING.md` or `SECURITY.md`; `bin/setup` and the toolchain (`.ruby-version` 3.2.2, Bundler 4.0.4 in the lockfile) are not mentioned anywhere. - -**Recommended solution.** Fix the prose, add a "Behaviour reference" section that answers the bullets above, add a support matrix, and a short contributing section. Update again when 3.3 and section 4 land. - -### 5.7 Performance - -Priority: Low. Category: Performance. Effort: folded into 3.1. Status: Suggestion. - -**Evidence.** For each object, `match` walks the tree for `same_key_structure?` (`schema_match.rb:30-33`), then `compare` walks both sides again for `deep_key_paths` and calls `dig_path` from the root for every leaf path (`schema_match.rb:35-62`), and this repeats for every element of every array. Diffy shells out to `diff(1)` on each failure. - -**Problem and impact.** Not measurable on typical API payloads; a few thousand keys would still finish in milliseconds. It is listed because 3.1 replaces all of it with a single walk, so no separate work is warranted. - -### 5.8 Observability - -Not applicable to a test-matcher gem in the usual sense. The equivalent concern is failure-message quality, which is 3.1 and 4.7. - -### 5.9 Local development setup - -Priority: Low. Category: Developer experience. Effort: S. Status: Confirmed. - -**Evidence.** `.ruby-version` pins 3.2.2, `Gemfile.lock` says `BUNDLED WITH 4.0.4` and lists `arm64-darwin-25` plus a `nokogiri` build for it, so the lockfile was last resolved on a newer Ruby than the one the repo declares. `mise.toml` is gitignored (commit `87fddee`). `bin/setup` only runs `bundle install`. - -**Recommended solution.** Decide on one declared development Ruby (3.4 or 4.0), regenerate the lockfile on it, and say in the README which Ruby and Bundler the lockfile expects. - -## Phased plan - -**Phase 1, released as 1.6.0. Done.** 1.1, 1.3, 1.4, 1.6, 1.7, 2.3, 2.4, 3.2, 5.3, 5.5, with a spec each. The suite went from 63 examples to 89, `bundler-audit` reports no vulnerabilities, and the packaged gem dropped from 39 files to 20. Not yet published to RubyGems. - -**Phase 2, 2.0.0 (one to two weeks).** 1.2, 1.5, 3.4, 3.9 (behaviour changes bundled in one CHANGELOG), 2.1 and 2.2 (dependency cut), 2.5, 3.7, 5.1 unit specs, 5.2 matrix rework, 5.6 documentation. Bump the major because 1.2, 1.5 and 2.1 can each break an existing suite. - -**Phase 3, 2.x (ongoing).** 3.3 DSL helpers, then 4.1, 4.2, 4.8 on top of them; 3.1 mismatch reporting once the DSL is settled; 4.3, 4.4, 4.5, 4.6, 4.7; 5.4 release automation; 1.8 and 3.6 disappear as part of 3.1. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cbfb355 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +## Supported Versions + +Security fixes are made on the latest released version. + +## Reporting a Vulnerability + +Please do not open a public issue for a suspected vulnerability. Use [GitHub's private security advisory form](https://github.com/nomtek/rspec-json_api/security/advisories/new) and include reproduction steps, affected versions, and the expected impact. diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile deleted file mode 100644 index 67c9551..0000000 --- a/gemfiles/rails_7_1.gemfile +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -# Inherits the dev dependencies from the root Gemfile and pins the Rails line -# under test. Generated/maintained for the CI compatibility matrix. -eval_gemfile File.expand_path("../Gemfile", __dir__) - -gem "rails", "~> 7.1.0" diff --git a/gemfiles/rails_7_2.gemfile b/gemfiles/rails_7_2.gemfile deleted file mode 100644 index 7380461..0000000 --- a/gemfiles/rails_7_2.gemfile +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -# Inherits the dev dependencies from the root Gemfile and pins the Rails line -# under test. Generated/maintained for the CI compatibility matrix. -eval_gemfile File.expand_path("../Gemfile", __dir__) - -gem "rails", "~> 7.2.0" diff --git a/gemfiles/rails_8_0.gemfile b/gemfiles/rails_8_0.gemfile deleted file mode 100644 index bab2dbc..0000000 --- a/gemfiles/rails_8_0.gemfile +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -# Inherits the dev dependencies from the root Gemfile and pins the Rails line -# under test. Generated/maintained for the CI compatibility matrix. -eval_gemfile File.expand_path("../Gemfile", __dir__) - -gem "rails", "~> 8.0.0" diff --git a/lib/rspec/json_api.rb b/lib/rspec/json_api.rb index 9ef16f7..bc1da63 100644 --- a/lib/rspec/json_api.rb +++ b/lib/rspec/json_api.rb @@ -4,10 +4,11 @@ require "json" require "uri" require "diffy" -require "active_support/core_ext/object/blank" +require "rspec/expectations" # Load the json_api parts require "rspec/json_api/version" +require "rspec/json_api/blank" require "rspec/json_api/traversal" require "rspec/json_api/constraints" require "rspec/json_api/schema_match" diff --git a/lib/rspec/json_api/blank.rb b/lib/rspec/json_api/blank.rb new file mode 100644 index 0000000..6b139c1 --- /dev/null +++ b/lib/rspec/json_api/blank.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module RSpec + module JsonApi + module Blank + module_function + + BLANK_STRING = /\A[[:space:]]*\z/ + + def blank?(value) + case value + when nil, false + true + when String + BLANK_STRING.match?(value) + else + value.respond_to?(:empty?) && value.empty? + end + end + end + end +end diff --git a/lib/rspec/json_api/constraints.rb b/lib/rspec/json_api/constraints.rb index 7c048d2..7b3c83f 100644 --- a/lib/rspec/json_api/constraints.rb +++ b/lib/rspec/json_api/constraints.rb @@ -21,7 +21,7 @@ module Constraints def match(value, options) validate!(options) - return true if value.blank? && options[:allow_blank] + return true if Blank.blank?(value) && options[:allow_blank] options.except(:allow_blank).all? do |option, condition| satisfies?(value, option, condition) @@ -42,7 +42,7 @@ def satisfies?(value, option, condition) when :type then value.instance_of?(condition) when :value then value == condition when :inclusion then condition.include?(value) - when :regex then condition.match?(value.to_s) + when :regex then matches_regexp?(value, condition) when :lambda then condition.call(value) when :min, :max then within_bound?(value, option, condition) end @@ -53,6 +53,10 @@ def within_bound?(value, option, condition) option == :min ? value >= condition : value <= condition end + + def matches_regexp?(value, condition) + value.is_a?(String) && condition.match?(value) + end end end end diff --git a/lib/rspec/json_api/matchers/match_json_schema.rb b/lib/rspec/json_api/matchers/match_json_schema.rb index d1ba310..cb60f48 100644 --- a/lib/rspec/json_api/matchers/match_json_schema.rb +++ b/lib/rspec/json_api/matchers/match_json_schema.rb @@ -45,6 +45,10 @@ def does_not_match?(actual) !matches?(actual) && !@type_error end + def description + "match JSON schema" + end + # Provides a failure message for when the JSON data does not match the expected schema. # @return [String] A descriptive message detailing the mismatch between expected and actual JSON. def failure_message diff --git a/lib/rspec/json_api/schema_match.rb b/lib/rspec/json_api/schema_match.rb index 49dd198..20f61e1 100644 --- a/lib/rspec/json_api/schema_match.rb +++ b/lib/rspec/json_api/schema_match.rb @@ -10,31 +10,28 @@ module JsonApi module SchemaMatch module_function - # Top-level comparison. Applies the shape guards (class equality and, for - # objects, key-set equality) before recursing. + # Top-level comparison. Applies shape guards to objects and collections, + # then uses the same value dispatch as nested schema values. def match(actual, expected) - return false unless actual.instance_of?(expected.class) - case expected when Array compare_array(actual, expected) when Hash + return false unless actual.is_a?(Hash) return false unless same_key_structure?(actual, expected) compare(actual, expected) else - compare_simple_value(actual, expected) + compare_values(actual, expected) end end def same_key_structure?(actual, expected) - Traversal.deep_sort(Traversal.deep_keys(actual)) == - Traversal.deep_sort(Traversal.deep_keys(expected)) + Traversal.same_key_structure?(actual, expected) end def compare(actual, expected) return false unless actual.is_a?(Hash) - return false if actual.blank? && expected.present? keys = Traversal.deep_key_paths(expected) | Traversal.deep_key_paths(actual) @@ -77,7 +74,7 @@ def compare_class(actual_value, expected_value) end def compare_regexp(actual_value, expected_value) - expected_value.match?(actual_value.to_s) + actual_value.is_a?(String) && expected_value.match?(actual_value) end # A schema Proc describes the constraints for a value; it is called without @@ -142,7 +139,7 @@ def compare_exact_array(actual_value, expected_value) return false if actual_value.size != expected_value.size expected_value.each_with_index.all? do |elem, index| - elem.is_a?(Hash) ? compare(actual_value[index], elem) : compare_values(actual_value[index], elem) + elem.is_a?(Hash) ? match(actual_value[index], elem) : compare_values(actual_value[index], elem) end end diff --git a/lib/rspec/json_api/traversal.rb b/lib/rspec/json_api/traversal.rb index f326b66..d2c9ead 100644 --- a/lib/rspec/json_api/traversal.rb +++ b/lib/rspec/json_api/traversal.rb @@ -44,6 +44,22 @@ def deep_sort(array) .map { |element| element.is_a?(Array) ? deep_sort(element) : element } .sort_by { |element| element.is_a?(Array) ? element.first.to_s : element.to_s } end + + # Whether two hashes have the same keys under the same parent objects. + def same_key_structure?(actual, expected) + same_keys?(actual, expected) && actual.all? do |key, actual_value| + same_nested_key_structure?(actual_value, expected[key]) + end + end + + def same_keys?(actual, expected) + actual.size == expected.size && actual.each_key.all? { |key| expected.key?(key) } + end + + def same_nested_key_structure?(actual, expected) + nested = actual.is_a?(Hash) || expected.is_a?(Hash) + !nested || (actual.is_a?(Hash) && expected.is_a?(Hash) && same_key_structure?(actual, expected)) + end end end end diff --git a/lib/rspec/json_api/version.rb b/lib/rspec/json_api/version.rb index 551928b..8c32d4d 100644 --- a/lib/rspec/json_api/version.rb +++ b/lib/rspec/json_api/version.rb @@ -2,6 +2,6 @@ module RSpec module JsonApi - VERSION = "1.6.0" + VERSION = "2.0.0" end end diff --git a/rspec-json_api.gemspec b/rspec-json_api.gemspec index 60a3501..8473210 100644 --- a/rspec-json_api.gemspec +++ b/rspec-json_api.gemspec @@ -24,19 +24,14 @@ Gem::Specification.new do |spec| # repository's own tooling out without letting untracked artefacts in. spec.files = Dir.chdir(File.expand_path(__dir__)) do `git ls-files -z lib`.split("\x0").select { |f| File.file?(f) }.sort + - %w[CHANGELOG.md LICENSE.txt README.md] + %w[CHANGELOG.md CONTRIBUTING.md LICENSE.txt README.md SECURITY.md] end spec.require_paths = ["lib"] - # Runtime dependencies. The gem only needs ActiveSupport's blank?/present? - # core extensions and Rails::Generators (which lives in railties); depending - # on the full "rails" meta-gem would force ActiveRecord, ActionCable, - # ActionMailer, ActionMailbox, ActiveStorage, ActionText, etc. on every - # consumer of a JSON-matcher gem. The >= 6.1.4.1 floor is unchanged. - spec.add_dependency "activesupport", ">= 6.1.4.1" + # Matchers work in plain RSpec projects. Rails is needed only while developing + # and testing the optional generators, so consumers do not inherit its stack. spec.add_dependency "diffy", ">= 3.4.2" - spec.add_dependency "railties", ">= 6.1.4.1" - spec.add_dependency "rspec-rails", ">= 5.0.2" + spec.add_dependency "rspec-expectations", "~> 3.0" # For more information and examples about making a new gem, checkout our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/spec/generators/generators_spec.rb b/spec/generators/generators_spec.rb new file mode 100644 index 0000000..6cdfbc3 --- /dev/null +++ b/spec/generators/generators_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "logger" +require "rails/generators" +require "tmpdir" + +RSpec.describe "RSpec::JsonApi generators", :generator do + around do |example| + Dir.mktmpdir("rspec-json-api-generators") do |destination| + @destination_root = destination + example.run + end + end + + it "installs the type and interface directories" do + Rails::Generators.invoke("rspec:json_api:install", [], destination_root: @destination_root) + + expect(File).to exist(File.join(@destination_root, "spec/rspec/json_api/types")) + expect(File).to exist(File.join(@destination_root, "spec/rspec/json_api/interfaces")) + end + + it "generates a frozen interface constant" do + Rails::Generators.invoke("rspec:json_api:interface", ["person"], destination_root: @destination_root) + + generated = File.read(File.join(@destination_root, "spec/rspec/json_api/interfaces/person.rb")) + expect(generated).to include("PERSON = {", "}.freeze", "# frozen_string_literal: true") + end + + it "generates a type constant" do + Rails::Generators.invoke("rspec:json_api:type", ["color_hex"], destination_root: @destination_root) + + generated = File.read(File.join(@destination_root, "spec/rspec/json_api/types/color_hex.rb")) + expect(generated).to include("COLOR_HEX = //", "# frozen_string_literal: true") + end +end diff --git a/spec/rspec/json_api/blank_spec.rb b/spec/rspec/json_api/blank_spec.rb new file mode 100644 index 0000000..eb94717 --- /dev/null +++ b/spec/rspec/json_api/blank_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe RSpec::JsonApi::Blank do + describe ".blank?" do + it "recognizes blank JSON values" do + [nil, false, "", " \t\n", "\u00A0", [], {}].each do |value| + expect(described_class.blank?(value)).to be(true), "expected #{value.inspect} to be blank" + end + end + + it "rejects present JSON values" do + [true, 0, "value", [nil], { key: nil }].each do |value| + expect(described_class.blank?(value)).to be(false), "expected #{value.inspect} to be present" + end + end + end +end diff --git a/spec/rspec/json_api/constraints_spec.rb b/spec/rspec/json_api/constraints_spec.rb index 055b36c..9965b8f 100644 --- a/spec/rspec/json_api/constraints_spec.rb +++ b/spec/rspec/json_api/constraints_spec.rb @@ -24,5 +24,21 @@ expect(described_class.match(5, type: Integer, min: 3, max: 10)).to be(true) expect(described_class.match(2, type: Integer, min: 3)).to be(false) end + + it "applies inclusion, regex, and lambda constraints" do + expect(described_class.match("red", inclusion: %w[red blue])).to be(true) + expect(described_class.match("ABC-123", regex: /\A[A-Z]+-\d+\z/)).to be(true) + expect(described_class.match(4, lambda: lambda(&:even?))).to be(true) + end + + it "rejects non-String values for regex constraints" do + expect(described_class.match(123, regex: /\A\d+\z/)).to be(false) + expect(described_class.match(nil, regex: /.*/)).to be(false) + end + + it "rejects non-numeric bounds" do + expect(described_class.match("5", min: 3)).to be(false) + expect(described_class.match(5, max: "10")).to be(false) + end end end diff --git a/spec/rspec/json_api/gemspec_spec.rb b/spec/rspec/json_api/gemspec_spec.rb index 13e3eb7..498c1f5 100644 --- a/spec/rspec/json_api/gemspec_spec.rb +++ b/spec/rspec/json_api/gemspec_spec.rb @@ -20,7 +20,7 @@ end it "packages the licence and the reference documents" do - expect(gemspec.files).to include("LICENSE.txt", "README.md", "CHANGELOG.md") + expect(gemspec.files).to include("LICENSE.txt", "README.md", "CHANGELOG.md", "CONTRIBUTING.md", "SECURITY.md") end it "packages every tracked file under lib and nothing else from there" do @@ -30,6 +30,12 @@ end it "packages nothing outside lib but the licence and the reference documents" do - expect(gemspec.files.grep_v(%r{\Alib/})).to contain_exactly("CHANGELOG.md", "LICENSE.txt", "README.md") + expect(gemspec.files.grep_v(%r{\Alib/})).to contain_exactly( + "CHANGELOG.md", "CONTRIBUTING.md", "LICENSE.txt", "README.md", "SECURITY.md" + ) + end + + it "keeps the runtime dependency set Rails-free" do + expect(gemspec.runtime_dependencies.map(&:name)).to contain_exactly("diffy", "rspec-expectations") end end diff --git a/spec/rspec/json_api/matchers/match_json_schema_spec.rb b/spec/rspec/json_api/matchers/match_json_schema_spec.rb index 366acec..9c7a32b 100644 --- a/spec/rspec/json_api/matchers/match_json_schema_spec.rb +++ b/spec/rspec/json_api/matchers/match_json_schema_spec.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true -require "rspec/json_api/interfaces/example_interface" +require_relative "../../../support/example_interface" RSpec.describe "match_json_schema matcher" do + it "describes itself for RSpec output" do + expect(match_json_schema({ id: String }).description).to eq("match JSON schema") + end + shared_examples "correct-match" do it "matches expected schema" do expect(actual).to match_json_schema(expected) diff --git a/spec/rspec/json_api/schema_match_spec.rb b/spec/rspec/json_api/schema_match_spec.rb new file mode 100644 index 0000000..b1229e3 --- /dev/null +++ b/spec/rspec/json_api/schema_match_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe RSpec::JsonApi::SchemaMatch do + describe ".match" do + where = [ + ["matches a root Class schema", "value", String, true], + ["rejects the wrong type for a root Class schema", 1, String, false], + ["matches a root Regexp schema", "abc-123", /\A[a-z]+-\d+\z/, true], + ["rejects a non-String for a root Regexp schema", 123, /\A\d+\z/, false], + ["matches a root literal", 123, 123, true], + ["rejects a different root literal", 123, 456, false] + ] + + where.each do |description, actual, expected, result| + it description do + expect(described_class.match(actual, expected)).to be(result) + end + end + + it "rejects an extra null-valued key inside an exact array" do + actual = [{ id: 1, extra: nil }, { id: 2 }] + expected = [{ id: Integer }, { id: Integer }] + + expect(described_class.match(actual, expected)).to be(false) + end + + it "rejects a missing allow-blank key inside an exact array" do + actual = [{ id: 1 }, { id: 2 }] + expected = [ + { id: Integer, name: -> { { type: String, allow_blank: true } } }, + { id: Integer } + ] + + expect(described_class.match(actual, expected)).to be(false) + end + + it "rejects nested keys attached to different parents" do + actual = { primary: { id: nil }, secondary: { name: nil } } + expected = { primary: { name: nil }, secondary: { id: nil } } + + expect(described_class.match(actual, expected)).to be(false) + end + + it "rejects nested keys attached to different parents inside an exact array" do + actual = [{ primary: { id: nil }, secondary: { name: nil } }, { id: 1 }] + expected = [{ primary: { name: nil }, secondary: { id: nil } }, { id: Integer }] + + expect(described_class.match(actual, expected)).to be(false) + end + + it "rejects a non-String value for a nested Regexp schema" do + expect(described_class.match({ code: 123 }, { code: /\A\d+\z/ })).to be(false) + end + + it "rejects nil for a nested permissive Regexp schema" do + expect(described_class.match({ code: nil }, { code: /.*/ })).to be(false) + end + end +end diff --git a/spec/rspec/json_api/traversal_spec.rb b/spec/rspec/json_api/traversal_spec.rb new file mode 100644 index 0000000..6dfc901 --- /dev/null +++ b/spec/rspec/json_api/traversal_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +RSpec.describe RSpec::JsonApi::Traversal do + describe ".deep_keys" do + it "collects nested object keys" do + expect(described_class.deep_keys({ id: 1, profile: { name: "Ada" } })) + .to eq([:id, :profile, [:name]]) + end + end + + describe ".deep_key_paths" do + it "returns every leaf path" do + value = { id: 1, profile: { name: "Ada", tags: ["ruby"] } } + + expect(described_class.deep_key_paths(value)) + .to contain_exactly([:id], %i[profile name], %i[profile tags]) + end + end + + describe ".deep_sort" do + it "sorts nested key collections recursively" do + expect(described_class.deep_sort([:z, %i[c b], :a])) + .to eq([:a, %i[b c], :z]) + end + end +end diff --git a/spec/rspec/json_api_spec.rb b/spec/rspec/json_api_spec.rb index 1307511..39b5e99 100644 --- a/spec/rspec/json_api_spec.rb +++ b/spec/rspec/json_api_spec.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "open3" +require "rbconfig" + RSpec.describe RSpec::JsonApi do it "has a version number" do expect(RSpec::JsonApi::VERSION).not_to be nil @@ -9,4 +12,15 @@ expect({}).not_to respond_to(:deep_keys, :deep_key_paths, :sanitize!) expect([]).not_to respond_to(:deep_sort) end + + it "does not load ActiveSupport core extensions" do + script = <<~RUBY + require "rspec/json_api" + abort "ActiveSupport core extensions loaded" if Object.new.respond_to?(:blank?) + RUBY + + _stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-Ilib", "-e", script) + + expect(status).to be_success, stderr + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e4d753f..3c5ec93 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,12 @@ # frozen_string_literal: true +require "simplecov" + +SimpleCov.start do + add_filter "/spec/" + minimum_coverage 90 +end + require "rspec/json_api" RSpec.configure do |config| @@ -8,6 +15,8 @@ # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! + config.order = :random + config.warnings = true config.expect_with :rspec do |c| c.syntax = :expect diff --git a/lib/rspec/json_api/interfaces/example_interface.rb b/spec/support/example_interface.rb similarity index 100% rename from lib/rspec/json_api/interfaces/example_interface.rb rename to spec/support/example_interface.rb