From 39b1e6ea8eff97c2536d2ad69f128de45413bea3 Mon Sep 17 00:00:00 2001 From: Tad Thorley Date: Sat, 4 Jul 2026 17:20:52 -0600 Subject: [PATCH] Adds support for request schema --- CHANGELOG.md | 26 + README.md | 38 + docs/request-schema.md | 256 +++++ docs/testing.md | 20 +- docs/validation.md | 4 + lib/action_figure.rb | 9 + lib/action_figure/core.rb | 77 +- lib/action_figure/core/request_validation.rb | 87 ++ lib/action_figure/request_schema.rb | 247 +++++ lib/action_figure/testing/minitest.rb | 43 +- lib/action_figure/testing/rspec.rb | 14 +- lib/action_figure/testing/statuses.rb | 46 + sig/action_figure.rbs | 90 +- test/action_figure/request_schema_test.rb | 924 ++++++++++++++++++ .../testing/minitest_contract_test.rb | 79 ++ test/action_figure/testing/rspec_test.rb | 29 + 16 files changed, 1950 insertions(+), 39 deletions(-) create mode 100644 docs/request-schema.md create mode 100644 lib/action_figure/core/request_validation.rb create mode 100644 lib/action_figure/request_schema.rb create mode 100644 test/action_figure/request_schema_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index e03a360..3927c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to ActionFigure will be documented in this file. +## [Unreleased] + +### Added + +- **`request_schema`** class macro — a location-aware alternative to `params_schema`. Declares the HTTP request in OpenAPI's vocabulary via **`path`**/**`query`**/**`body`** locations. The block form declares; the no-args form returns the compiled **`ActionFigure::RequestSchema`** (per-location coercing contracts via `.contracts`, typed value construction), mirroring the `api_version` block/reader duality. `params_schema` is unchanged and not deprecated; an action class declares one or the other, never both. +- Class-load guards for `request_schema`, failing at boot with pointed messages: + - declaring both `params_schema` and `request_schema` (either order) raises `ArgumentError` + - duplicate `request_schema` raises `ArgumentError` (parity with the `params_schema` guard) + - `optional(...)` inside a `path` location raises — OpenAPI path parameters are always required + - schema keys named **`given?`**, **`given_keys`**, **`to_h`**, or **`deconstruct_keys`** (at any nesting depth, array members included) raise — these names are reserved by the typed request values + - bare `required`/`optional` declarations outside a location raise with guidance instead of `NoMethodError` + - a duplicate location block (`body { ... }` twice) raises instead of silently overwriting the first + - a location called without a block (`path` bare) raises instead of compiling to an empty schema + - `rules(:location)` declared above the `request_schema` block raises pointing at the declaration order +- dry-schema's **`:info`** extension is now loaded (powers the class-load guards). +- **`request:` invocation pipeline.** `request_schema` actions are called with `request:` (the Rails `ActionDispatch::Request` — duck-typed on `path_parameters`/`query_parameters`/`request_parameters`, so core stays framework-free) instead of `params:`. Each location validates against its actual source: a query-location key arriving in the body is not seen, and same-named keys in different locations are distinct parameters. Controllers: `render Action.update(request:, current_user:)`. +- **Typed request values.** The action method receives `request:` as a frozen, schema-shaped value (`Data`-based, generated once at class load): `request.path.id`, `request.query.workspace_id`, `request.body.name` — coerced and validated; typo'd key access raises `NoMethodError` at the call site. Actions declare only the locations they have. +- **Absent vs. explicit nil is preserved** (PATCH semantics): reads return `nil` for both, **`given?(:key)`** tells them apart, pattern matching (`in { description: }`) matches only keys the client actually sent, and **`to_h`** returns given keys only — matching dry-schema's key-omission behavior. +- **Nested typed values.** Nested hash schemas become nested frozen values (`request.body.project.settings.visibility`) and arrays of hashes become arrays of typed values — with `given?`/`given_keys` at every level. Typed exactly as deep as the contract is explicit: a blockless `hash` (free-form JSON) stays a plain hash. **`to_h`** returns given keys as plain hashes all the way down (safe for `Model.update(request.body.to_h)`); pattern matching binds typed values. +- **`ActionFigure.request(path:, query:, body:)`** — request stand-in for invoking `request_schema` actions from tests and consoles. +- **Path-location failures render `NotFound`** (a malformed identity param means the resource cannot exist; identity wins on mixed failures); other validation failures render `UnprocessableContent` with the familiar flat `{field => [messages]}` errors, merged across locations — same-named keys failing in multiple locations concatenate their messages. +- **`whiny_extra_params` applies to `request_schema` actions** per location: undeclared keys in `query`/`body` return `422` with `"is not allowed"` errors. The `path` location is exempt (router-defined keys plus `:controller`/`:action`/`:format` bookkeeping). Undeclared locations are never read off the request — a `path`/`query`-only schema never triggers body parsing. +- Passing `params:` (or any non-request object) to a `request_schema` action raises `ArgumentError` pointing to `request:` / `ActionFigure.request` — no silent fallback to merged validation. +- **Location-aware contract assertions.** For `request_schema` actions, the contract helpers take locations: Minitest **`assert_valid_params(Action, query: {...}, body: {...})`** / **`assert_invalid_params(..., on: :field)`**; RSpec **`accept_params(query: {...}, body: {...})`** / **`reject_params(...).with_error_on(:field)`**. Omitted locations validate as empty (matching runtime); unknown location names raise listing the declared locations; errors report flat across locations. Omitting params entirely, or mixing a positional params hash with location keywords (including typo'd keywords like `om:`), raises `ArgumentError`. The existing hash forms for `params_schema` actions are unchanged. +- **Location-scoped `rules` for `request_schema`**: `rules(:body) { ... }`, `rules(:query) { ... }`, `rules(:path) { ... }` — one block per declared location, attaching to that location's contract with today's semantics (cross-param helpers included). Path-location rules failures render 404 like path schema failures; others 422. Guards at class load: bare `rules` on a `request_schema` action, rules for an undeclared location, duplicate `rules(location)`, and `rules(:location)` on a `params_schema` action all raise with pointed messages. Cross-location rules live in the method body. + ## [0.7.0] - 2026-07-02 ### Added diff --git a/README.md b/README.md index 451b767..a1bb365 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Every action class has three responsibilities: | Feature | Description | |---------|-------------| | [Validation](docs/validation.md) | Two-layer validation powered by dry-validation: structural schemas with type coercion, plus validation rules. Includes cross-parameter helpers like `exclusive_rule`, `any_rule`, `one_rule`, and `all_rule`. | +| [Request Schema](docs/request-schema.md) | Location-aware validation in OpenAPI's vocabulary: declare where each param arrives (`path`/`query`/`body`) and it's enforced. Actions receive a frozen, typed request value (`request.body.name`, `given?` for PATCH semantics); malformed path params render 404. | | [Response Formatters](docs/response-formatters.md) | Five built-in formats: Default, JSend, JSON:API, Wrapped, and RFC 9457. Each provides response helpers (`Ok`, `Created`, `NotFound`, etc.) that return render-ready hashes. | | [Problem Details](docs/problem-details.md) | RFC 9457 formatter (`:rfc_9457`) that renders errors as `application/problem+json` problem documents with `type`, `title`, `status`, `detail`, and `instance` members. Success responses mirror the same vocabulary. | | [Status Codes](docs/status-codes.md) | Which 4xx codes are domain concerns (handled by action classes) vs perimeter concerns (handled by middleware, router, or infrastructure). | @@ -177,6 +178,43 @@ class Search::LookupAction end ``` +### Location-Aware Requests + +For endpoints whose request shape is a published contract, `request_schema` declares **where** each param arrives — and enforces it. The action receives a frozen, typed request value: + +```ruby +class Projects::UpdateAction + include ActionFigure[:jsend] + + request_schema do + path { required(:id).filled(:integer) } + query { required(:workspace_id).filled(:integer) } + body do + required(:name).filled(:string) + optional(:description).maybe(:string) + end + end + + def update(request:, current_user:) + project = current_user.projects.find(request.path.id) # coerced integer + project.update(name: request.body.name) + # PATCH semantics: only touch fields the client actually sent + project.update(description: request.body.description) if request.body.given?(:description) + + Ok(resource: ProjectBlueprint.render_as_hash(project)) + end +end +``` + +```ruby +# controller +def update + render Projects::UpdateAction.update(request:, current_user:) +end +``` + +A query param sent in the body is not seen; a malformed path param renders 404. See [Request Schema](docs/request-schema.md). + ### Response Formatters Choose a response envelope by name. The same helpers return different shapes: diff --git a/docs/request-schema.md b/docs/request-schema.md new file mode 100644 index 0000000..e8b770c --- /dev/null +++ b/docs/request-schema.md @@ -0,0 +1,256 @@ +# Request Schema + +## Overview + +`request_schema` is a location-aware alternative to `params_schema`. Where `params_schema` validates the merged params hash Rails hands you, `request_schema` describes the HTTP request in OpenAPI's vocabulary — **where** each parameter arrives (`path`, `query`, or `body`) — and enforces it: a param documented as a query parameter is simply not seen if a client sends it in the body. + +```ruby +class Projects::UpdateAction + include ActionFigure[:jsend] + + request_schema do + path { required(:id).filled(:integer) } + query { required(:workspace_id).filled(:integer) } + body do + required(:name).filled(:string) + optional(:description).maybe(:string) + end + end + + def update(request:, current_user:) + project = current_user.projects.find(request.path.id) + project.update(name: request.body.name) + Ok(resource: ProjectBlueprint.render_as_hash(project)) + end +end +``` + +```ruby +# controller — `request` is already there (Ruby 3.1 shorthand) +def update + render Projects::UpdateAction.update(request:, current_user:) +end +``` + +Each location block uses the same [dry-schema](https://dry-rb.org/gems/dry-schema/) DSL as `params_schema`, with the same coercion (`"42"` → `42`). + +### Choosing a macro + +The two macros form a ladder with schema-less actions at the bottom — each rung adds contract strength as an endpoint's audience grows: + +1. **No schema** — params pass through untouched (health checks, actions relying on upstream validation). +2. **`params_schema`** — validates the merged params hash. The lightweight tier for endpoints with no external contract. +3. **`request_schema`** — validates by location, for endpoints whose request shape is a published contract (public APIs, OpenAPI documents). + +An action class declares one or the other, never both — a second declaration of either kind raises `ArgumentError` at class load. `params_schema` is not deprecated and its behavior is unchanged. + +--- + +## Calling convention: `request:` + +`request_schema` actions take `request:` instead of `params:` — the Rails request object (`ActionDispatch::Request`) in controllers, or a stand-in built with `ActionFigure.request` everywhere else (a plain `Rack::Request` does not qualify — the duck type needs `path_parameters`/`query_parameters`/`request_parameters`, which ActionDispatch adds): + +```ruby +# controller +Projects::UpdateAction.update(request:, current_user:) + +# test or console +Projects::UpdateAction.update( + request: ActionFigure.request( + path: { id: "7" }, + query: { workspace_id: "42" }, + body: { name: "Roadmap" } + ), + current_user: user +) +``` + +ActionFigure duck-types on `path_parameters` / `query_parameters` / `request_parameters`, so core stays framework-free. Passing `params:` — or anything that doesn't quack like a request — raises `ArgumentError` pointing you at `request:` / `ActionFigure.request`. There is no silent fallback to merged validation: that would reopen the accepts-params-anywhere hole the macro exists to close. + +`request:` is a reserved keyword argument on `request_schema` actions; other keyword arguments (`current_user:`, etc.) pass through untouched, exactly as with `params:` actions. + +--- + +## Validation semantics + +**Each location validates against its actual source.** Rails merges query, body, and path params into one hash (precedence: path > body > query), which makes location enforcement impossible after the fact — an action documented as taking `workspace_id` in the query would silently accept it from the body, and clients end up depending on undocumented behavior. `request_schema` never merges: + +- A `query`-location key arriving via the body is **not seen** (schema-as-filter, same posture as `params_schema`). +- `query` and `body` may both declare the same key — they are distinct parameters, as they are in OpenAPI, addressed as `request.query.limit` and `request.body.limit`. When the same key fails in more than one location, the error messages concatenate under that key. + +**Failure statuses follow the location:** + +| Failing location | Status | Why | +|---|---|---| +| `path` | `404 NotFound` | A malformed identity param (`GET /projects/abc` with integer `id`) means the resource cannot exist — matching what `find` would produce. Identity wins on mixed failures. | +| `query`, `body` | `422 UnprocessableContent` | The familiar flat `{field => [messages]}` errors, merged across locations. | + +**Boot-time guards** — declaration mistakes fail at class load, not at request time: + +- `optional(...)` inside a `path` location raises (OpenAPI path parameters are always required). +- Schema keys named `given?`, `given_keys`, `to_h`, or `deconstruct_keys` raise at any nesting depth — those names are reserved by the typed request values. +- Bare `required`/`optional` outside a location block raises with guidance. +- A duplicate location block (`body { ... }` twice) raises — silently overwriting the first would drop its validations. +- A location called without a block (`path` bare) raises — it would otherwise compile to an empty schema that validates everything. +- `rules(:location)` above the `request_schema` block raises, pointing at the declaration order. + +**`whiny_extra_params` applies here too.** With [`whiny_extra_params`](configuration.md) enabled, undeclared keys in `query` or `body` return `422` with `"is not allowed"` errors, same as `params_schema`. The `path` location is exempt: the router, not the client, defines path keys, and `path_parameters` carries bookkeeping entries (`:controller`, `:action`, `:format`). + +--- + +## The typed request value + +Your method receives `request:` — not the Rails request, but a frozen, validated value shaped exactly like your schema (the same transform `params:` performs today: framework object in, validated data out). Locations are readers, keys are methods: + +```ruby +request.path.id # => 7 (coerced) +request.query.workspace_id # => 42 +request.body.name # => "Roadmap" +request.body.naem # => NoMethodError — typos fail at the call site +``` + +Nested hash schemas become nested values, and arrays of hashes become arrays of values: + +```ruby +body do + required(:project).hash do + required(:name).filled(:string) + required(:tags).array(:hash) do + required(:label).filled(:string) + end + end +end + +request.body.project.name # => "Roadmap" +request.body.project.tags.first.label # => "api" +``` + +Values are typed **exactly as deep as the contract is explicit**: a blockless `hash` (free-form JSON — metadata blobs, pass-through payloads) has no declared shape, so it stays a plain hash. Its interior keeps JSON's string keys and sits outside validation — the typing boundary and the contract boundary are the same line. + +### Absent vs. explicit nil (`given?`) + +For PATCH semantics, "the client didn't send `description`" and "the client sent `description: null`" must stay distinguishable. Reads return `nil` for both; the given-key set tells them apart: + +```ruby +request.body.description # => nil (either case — reads stay simple) +request.body.given?(:description) # => false if omitted, true if sent (even as null) +request.body.given_keys # => the frozen set of keys the client sent + +# PATCH: only touch fields the client sent +project.update(description: request.body.description) if request.body.given?(:description) +``` + +Pattern matching matches **only given keys**, and bindings stay typed: + +```ruby +case request.body +in { description: } # matches only when the client sent it (even as null) + update_description(description) +else # absent → leave the field alone +end +``` + +`to_h` returns given keys only, as plain hashes all the way down — safe to hand straight to a model: + +```ruby +project.update(request.body.to_h) +``` + +--- + +## Location-scoped rules + +`rules` on a `request_schema` action names the location it constrains — one block per declared location, with the same semantics as `params_schema` rules (cross-param helpers included; rules run only on keys that passed the schema): + +```ruby +request_schema do + query do + required(:from).filled(:date) + required(:to).filled(:date) + end + body do + optional(:user_id).filled(:integer) + optional(:email).filled(:string) + end +end + +rules(:query) do + rule(:from, :to) do + key(:from).failure("must be before to") if values[:from] > values[:to] + end +end + +rules(:body) do + exclusive_rule(:user_id, :email, "provide one, not both") +end +``` + +Failure statuses follow the location, as above: `rules(:path)` failures render 404, others 422. + +Guards at class load: bare `rules` on a `request_schema` action raises (name the location); rules for an undeclared location raise, listing the declared ones; a duplicate `rules(:location)` raises; and `rules(:location)` on a `params_schema` action raises (locations are a `request_schema` concept — bare block there, unchanged). + +Cross-**location** rules (a query param exclusive with a body field) are rare and usually an API-design smell; when needed, express them in the method body with an explicit `UnprocessableContent(...)` return. + +--- + +## Testing + +See the [testing guide](testing.md) for the full assertion reference. The short version — contract assertions take locations: + +```ruby +# Minitest +assert_valid_params(Projects::UpdateAction, query: { workspace_id: "1" }, body: { name: "x" }) +assert_invalid_params(Projects::UpdateAction, body: { name: "" }, on: :name) + +# RSpec +expect(Projects::UpdateAction).to accept_params(query: { workspace_id: "1" }, body: { name: "x" }) +expect(Projects::UpdateAction).to reject_params(body: { name: "x" }).with_error_on(:workspace_id) +``` + +Omitted locations validate as empty — matching runtime, where a missing required query param fails. Unknown location names raise, listing the declared locations. + +Full invocations use `ActionFigure.request`, which states where each param arrives — the exact claim your schema makes: + +```ruby +result = Projects::UpdateAction.update( + request: ActionFigure.request(path: { id: "7" }, body: { name: "Roadmap" }), + current_user: user +) + +assert_Ok(result) +``` + +--- + +## Introspection + +The no-args form returns the compiled schema (mirroring `api_version`): + +```ruby +Projects::UpdateAction.request_schema +# => # + +Projects::UpdateAction.request_schema.contracts +# => { path: #, query: #, body: # } +``` + +Locations with rules attached expose a `Dry::Validation::Contract`; schema-only locations expose a `Dry::Schema::Params`. + +--- + +## Limitations + +- **JSON bodies only.** Multipart/form-data and file uploads are not supported by `request_schema` — upload endpoints keep `params_schema` or handle the perimeter in the controller. (Signed-URL uploads sidestep this entirely: every endpoint stays JSON.) +- **Headers are a perimeter concern**, not a location. Auth (`Authorization`), content negotiation (`Accept`), and rate limiting belong to base controllers and middleware — see [Status Codes](status-codes.md). When a specific header *is* domain input (an `Idempotency-Key`, an `If-Match` ETag), extract it in the controller and pass it as a keyword argument, exactly like `current_user` or any other context: + + ```ruby + def create + render Payments::CreateAction.create( + request:, + current_user:, + idempotency_key: request.headers["Idempotency-Key"] + ) + end + ``` +- **Naming caveat:** `request.path` and `request.body` reuse OpenAPI's location vocabulary, which shadows well-known Rails request API names of different types (Rails' `request.path` is a URL string; `request.body` is an IO). The typed value is not the Rails request — a `NoMethodError` on the missing Rails API makes the confusion loud. +- Like `params_schema`, `request_schema` state is not inherited by subclasses — define each action class independently. diff --git a/docs/testing.md b/docs/testing.md index 3085a7d..3c6c97a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -99,7 +99,7 @@ require "action_figure/testing/rspec" | `be_Locked` | `:locked` | | `be_UnavailableForLegalReasons` | `:unavailable_for_legal_reasons` | | `have_action_json` | `result[:json]` matches `a_hash_including(fragment)` | -| `accept_params(params)` | action class's contract accepts `params` | +| `accept_params(params)` | action class's contract accepts `params` (locations for `request_schema` actions) | | `reject_params(params)` | action class's contract rejects `params` (chain `.with_error_on(:field)`) | Like the Minitest helpers, each **`be_*`** matcher compares **only `result[:status]`** — **`[:json]`** is ignored unless you assert on it separately. Use **`have_action_json`** when you want a focused assertion against the **`json`** body (compose with **`a_hash_including`** for nested subsets): @@ -340,7 +340,23 @@ RSpec.describe Users::CreateAction do end ``` -Both adapters raise a clear **`ArgumentError`** when the action class declares no **`params_schema`** (and therefore has no contract to validate against). +**`request_schema` actions take locations instead of a params hash** — the assertion states where each param arrives, which is exactly the claim the schema makes: + +```ruby +# Minitest +assert_valid_params(Projects::UpdateAction, query: { workspace_id: "1" }, body: { name: "x" }) +assert_invalid_params(Projects::UpdateAction, body: { name: "" }, on: :name) + +# RSpec +expect(Projects::UpdateAction).to accept_params(query: { workspace_id: "1" }, body: { name: "x" }) +expect(Projects::UpdateAction).to reject_params(body: { name: "x" }).with_error_on(:workspace_id) +``` + +Omitted locations validate as empty (matching runtime — a missing required query param fails); unknown location names raise an `ArgumentError` listing the declared locations; errors report flat across locations (same-named keys failing in multiple locations concatenate their messages). Full invocations of `request_schema` actions in tests use [`ActionFigure.request`](request-schema.md#calling-convention-request) to build the request stand-in. + +Calling an assertion with no params at all, or mixing a positional params hash with location keywords (including a typo'd keyword like `om:` for `on:`), raises `ArgumentError` — neither mistake can pass silently. + +Both adapters raise a clear **`ArgumentError`** when the action class declares no schema at all (and therefore has no contract to validate against). > **Validation errors vs. error bodies.** These helpers are the right tool for asserting *validation* behavior. There is no formatter-agnostic helper for **non-validation** error bodies (e.g. a `NotFound`/`Conflict` you return with a custom `errors:` payload) — each formatter stores those differently and the result hash carries no formatter identity. Assert those with a format-specific `assert_action_json` / `have_action_json`. diff --git a/docs/validation.md b/docs/validation.md index e69020a..229ed3f 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -11,6 +11,8 @@ The two layers are: If no `params_schema` is defined, `params:` passes through to your `#call` method as-is — no validation, no coercion, no stripping of extra keys. This lets you rely on upstream validation (e.g., Rack middleware like `committee`) while still using ActionFigure for orchestration and response formatting. +> **Location-aware validation:** for endpoints whose request shape is a published contract, [`request_schema`](request-schema.md) declares **where** each parameter arrives (`path`/`query`/`body`) and enforces it, handing your method a typed request value. This guide covers `params_schema`, which validates the merged params hash and remains the right tool for endpoints without an external contract. + --- ## params_schema @@ -270,6 +272,8 @@ With this enabled, passing undeclared parameters returns an `UnprocessableConten Each extra key receives its own `"is not allowed"` error message. This check runs **after** schema validation succeeds, so you will see schema errors or extra-param errors, never both at the same time. +The same check applies to [`request_schema`](request-schema.md) actions, per location: undeclared keys in `query` or `body` are rejected. The `path` location is exempt (the router defines path keys, and `path_parameters` carries `:controller`/`:action`/`:format` bookkeeping). + --- ## ActionController::Parameters diff --git a/lib/action_figure.rb b/lib/action_figure.rb index 620ce48..d7a9ed2 100644 --- a/lib/action_figure.rb +++ b/lib/action_figure.rb @@ -5,6 +5,7 @@ require_relative "action_figure/format_registry" require_relative "action_figure/error_registry" require_relative "action_figure/formatter" +require_relative "action_figure/request_schema" require_relative "action_figure/core" require_relative "action_figure/formatters/jsend" require_relative "action_figure/formatters/json_api" @@ -33,6 +34,14 @@ class InitializationNotSupportedError < StandardError; end register_formatter(wrapped: Formatters::Wrapped) register_formatter(rfc_9457: Formatters::Rfc9457) # rubocop:disable Naming/VariableNumber + # Duck-type stand-in for a Rails (ActionDispatch) request, for invoking request_schema + # actions from tests and consoles: ActionFigure.request(path: {...}, body: {...}). + RequestStub = Data.define(:path_parameters, :query_parameters, :request_parameters) + + def self.request(path: {}, query: {}, body: {}) + RequestStub.new(path_parameters: path, query_parameters: query, request_parameters: body) + end + def self.[](format = configuration.format) format_modules.compute_if_absent(format) { build_format_module(format, fetch(format)) } end diff --git a/lib/action_figure/core.rb b/lib/action_figure/core.rb index 44fb056..e437ff2 100644 --- a/lib/action_figure/core.rb +++ b/lib/action_figure/core.rb @@ -2,6 +2,10 @@ require "dry/validation" +require_relative "core/request_validation" + +Dry::Schema.load_extensions(:info) + module ActionFigure # Provides the validation pipeline and DSL mixed into action classes via ActionFigure.[]. module Core @@ -36,6 +40,18 @@ def all_rule(*fields, message) end end + # One factory for every validation contract class — params_schema contracts + # and request_schema location contracts — so contract-wide concerns + # (cross-param helpers, future config) cannot drift between the two. + def self.build_contract_class(schema_block, rules_block) + Class.new(Dry::Validation::Contract) do + extend CrossParamRuleHelpers + + params(&schema_block) + class_eval(&rules_block) if rules_block + end + end + # DSL class methods extended into action classes: params_schema, rules, entry_point. # # Note: ActionFigure does not support class inheritance. +params_schema+, +rules+, and @@ -43,16 +59,35 @@ def all_rule(*fields, message) # subclasses. Define each action class independently. module ClassMethods def params_schema(&block) - if @params_schema_block - raise ArgumentError, - "params_schema already defined — each action class may declare only one schema" - end + disallow_second_schema!(:params_schema) @params_schema_block = block @contract = nil end - def rules(&block) + # Block form declares and compiles the schema; no-args form returns the + # compiled ActionFigure::RequestSchema (or nil), mirroring api_version. + def request_schema(&) + return @request_schema unless block_given? + + disallow_second_schema!(:request_schema) + + @request_schema = RequestSchema.new(&) + end + + def rules(location = nil, &block) + return @request_schema.attach_rules(location, &block) if @request_schema + + if location && @params_schema_block + raise ArgumentError, + "rules(#{location.inspect}) — locations are a request_schema concept; " \ + "params_schema actions take a bare rules block" + end + if location + raise ArgumentError, + "rules(#{location.inspect}) requires request_schema to be declared first — " \ + "move the request_schema block above rules(#{location.inspect})" + end raise ArgumentError, "rules requires params_schema to be defined" unless @params_schema_block @rules_block = block @@ -92,6 +127,22 @@ def contract private + # Schemas are mutually exclusive and single-shot: declaring a second one + # of either kind raises at class load. + def disallow_second_schema!(declaring) + { params_schema: @params_schema_block, request_schema: @request_schema } + .each do |kind, existing| + next unless existing + + detail = if kind == declaring + "each action class may declare only one schema" + else + "an action class declares params_schema or request_schema, not both" + end + raise ArgumentError, "#{kind} already defined — #{detail}" + end + end + # no-op when notifications aren't turned on def notify yield @@ -130,17 +181,7 @@ def disallow_action_initialize(method_name) end def build_contract - schema_block = @params_schema_block - rules_block = @rules_block - - contract_class = Class.new(Dry::Validation::Contract) do - extend ActionFigure::Core::CrossParamRuleHelpers - - params(&schema_block) - class_eval(&rules_block) if rules_block - end - - contract_class.new + Core.build_contract_class(@params_schema_block, @rules_block).new end end @@ -153,6 +194,8 @@ def contract end def validated_call(**kwargs) + return request_validate_and_call(**kwargs) if self.class.request_schema + kwargs = normalize_params(kwargs) if contract && kwargs.key?(:params) @@ -180,6 +223,8 @@ def notify end end + include RequestValidation + private def normalize_params(kwargs) diff --git a/lib/action_figure/core/request_validation.rb b/lib/action_figure/core/request_validation.rb new file mode 100644 index 0000000..ee83d0f --- /dev/null +++ b/lib/action_figure/core/request_validation.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module ActionFigure + module Core + # The request_schema half of the validation pipeline, included into Core: + # reads location sources off the request object, validates each declared + # location, and invokes the entry point with the typed request value. + module RequestValidation + # Maps each schema location to the request reader that supplies it. Drives + # both the duck-type check and source reads. + LOCATION_SOURCES = { + path: :path_parameters, + query: :query_parameters, + body: :request_parameters + }.freeze + + # Messages a request-like object must answer to be accepted as request:. + REQUEST_DUCK_TYPE = LOCATION_SOURCES.values.freeze + + private + + def request_validate_and_call(**kwargs) + raw = kwargs[:request] + verify_request_duck_type!(raw) + + schema = self.class.request_schema + sources = request_sources(raw) + results = schema.validate(sources) + + failure = request_validation_failure(sources, results) + return failure if failure + + value = schema.build_value(results.transform_values(&:to_h)) + public_send(entry_point_name, **kwargs, request: value) + end + + def request_validation_failure(sources, results) + failures = results.select { |_location, result| result.failure? } + return request_failure_response(failures) if failures.any? + + check_extra_request_params(sources, results) + end + + def verify_request_duck_type!(raw) + return if REQUEST_DUCK_TYPE.all? { |message| raw.respond_to?(message) } + + raise ArgumentError, + "#{self.class} declares request_schema — pass request: " \ + "(the Rails request object (ActionDispatch::Request), or " \ + "ActionFigure.request(path:, query:, body:) in tests), got #{raw.inspect}" + end + + # The path location is exempt: the router, not the client, defines path keys, + # and path_parameters carries bookkeeping entries (:controller, :action, :format). + def check_extra_request_params(sources, results) + return unless ActionFigure.configuration.whiny_extra_params + + errors = sources.except(:path) + .map { |location, raw| find_extra_keys(raw, results.fetch(location).to_h) } + .reduce({}, :merge) + return if errors.empty? + + UnprocessableContent(errors: errors) + end + + # A path param failing its schema means the resource identity is malformed; + # 404 matches what a find would produce. Identity wins on mixed failures. + def request_failure_response(failures) + errors = RequestSchema.merge_errors(failures.values) + return NotFound(errors: errors) if failures.key?(:path) + + UnprocessableContent(errors: errors) + end + + # Reads only the declared locations' sources — an undeclared body is never + # parsed. Router bookkeeping keys (:controller, :action, :format) in + # path_parameters need no stripping: locations validate declared keys only, + # and the value factory exposes schema members only, so undeclared keys + # never surface. + def request_sources(raw) + self.class.request_schema.contracts.keys.to_h do |location| + [location, raw.public_send(LOCATION_SOURCES.fetch(location))] + end + end + end + end +end diff --git a/lib/action_figure/request_schema.rb b/lib/action_figure/request_schema.rb new file mode 100644 index 0000000..e05fd28 --- /dev/null +++ b/lib/action_figure/request_schema.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +require "dry/validation" + +module ActionFigure + # The compiled form of a request_schema declaration: per-location coercing + # contracts plus the typed value classes handed to the action as +request:+. + # Built once at class load, where it also enforces the declaration guards + # (path params always required, no reserved key names). + class RequestSchema + LOCATIONS = %i[path query body].freeze + + # Method names the generated request values define themselves; schema keys + # with these names would be shadowed, so they are rejected at class load. + RESERVED_KEYS = %i[given? given_keys to_h deconstruct_keys].freeze + + # Instance behavior for generated location classes. Each carries the set of + # keys the client actually sent (+given_keys+), so absent and explicit-nil + # stay distinguishable (PATCH semantics): reads return nil for both, + # +given?+ tells them apart, and +to_h+/+deconstruct_keys+ expose given + # keys only. + module ValueBehavior + def given?(key) + given_keys.include?(key) + end + + # Given keys only, plain hashes all the way down — safe to hand straight + # to model layers. Pattern matching keeps typed values instead: see + # +deconstruct_keys+. + def to_h + super.slice(*given_keys).transform_values { |value| ValueBehavior.unwrap(value) } + end + + def deconstruct_keys(requested) + given = super(nil).slice(*given_keys) + requested.nil? ? given : given.slice(*requested) + end + + def self.unwrap(value) + case value + when ValueBehavior then value.to_h + when Array then value.map { |element| unwrap(element) } + else value + end + end + end + + # Collects the path/query/body location blocks of a request_schema declaration. + class Locations + attr_reader :blocks + + def initialize(&) + @blocks = {} + instance_eval(&) + end + + LOCATIONS.each do |location| + define_method(location) do |&blk| + if @blocks.key?(location) + raise ArgumentError, + "#{location} already declared — each location takes one block" + end + unless blk + raise ArgumentError, + "#{location} location requires a block — " \ + "e.g. #{location} { required(:key).filled(:string) }" + end + + @blocks[location] = blk + end + end + + %i[required optional].each do |declaration| + define_method(declaration) do |*| + raise ArgumentError, + "#{declaration} must be declared inside a path, query, or body location — " \ + "request_schema takes locations, not bare declarations" + end + end + end + + # One Shape per hash level of a schema, generated at class load: a Data class + # for the level's keys plus child shapes for nested hashes and arrays of + # hashes. Levels the schema doesn't describe (blockless +hash+) have no + # shape and stay plain hashes — typed exactly as deep as the contract is + # explicit. Validated hashes omit absent optional keys (dry-schema + # behavior), so each level's key set is exactly its given set. + class Shape + def initialize(keys_info) + @keys = keys_info.keys.freeze + @value_class = Data.define(*@keys, :given_keys) + @value_class.include(ValueBehavior) + @children = keys_info.filter_map do |key, meta| + child = child_shape(meta) + [key, child] if child + end.to_h + end + + def build(validated) + values = @keys.to_h { |key| [key, build_member(key, validated[key])] } + @value_class.new(**values, given_keys: validated.keys.freeze) + end + + private + + def child_shape(meta) + return Shape.new(meta[:keys]) if meta[:keys] + + member_keys = RequestSchema.member_keys(meta) + ArrayShape.new(Shape.new(member_keys)) if member_keys + end + + def build_member(member, value) + child = @children[member] + return value if child.nil? || value.nil? + + child.build(value) + end + end + + # Applies an element Shape across an array-of-hashes member. + class ArrayShape + def initialize(element_shape) + @element_shape = element_shape + end + + def build(values) + values.map { |value| @element_shape.build(value) } + end + end + + # dry-schema's info extension emits member: "integer" (a type name) for + # arrays of primitives and member: {keys: {...}} for arrays of hashes; + # only the latter carries nested keys. + def self.member_keys(meta) + member = meta[:member] + member[:keys] if member.is_a?(Hash) + end + + # Flattens per-location validation results into one errors hash. Same-named + # keys across locations are distinct parameters, so colliding messages + # concatenate (and nested hashes deep-merge) instead of last-location-wins. + def self.merge_errors(results) + results.map { |result| result.errors.to_h } + .reduce({}) { |merged, errors| deep_merge_errors(merged, errors) } + end + + def self.deep_merge_errors(left, right) + left.merge(right) do |_key, a, b| + a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge_errors(a, b) : Array(a) + Array(b) + end + end + private_class_method :deep_merge_errors + + attr_reader :contracts + + def initialize(&) + @ruled_locations = [] + @location_blocks = Locations.new(&).blocks + @contracts = @location_blocks.transform_values { |blk| Dry::Schema.Params(&blk) } + enforce_declaration_guards + @location_shapes = @contracts.transform_values { |schema| Shape.new(schema.info[:keys]) } + @container_class = Data.define(*@contracts.keys) + end + + # Attaches a rules block to the named location, rebuilding that location's + # contract as a Dry::Validation::Contract (schema + rules, cross-param + # helpers included). Locations are explicit: rules(:query) { ... }. + def attach_rules(location, &rules_block) + location_block = ruleable_location_block(location) + @ruled_locations << location + + contract_class = Core.build_contract_class(location_block, rules_block) + @contracts = @contracts.merge(location => contract_class.new) + end + + # Validates each declared location's source hash (missing sources validate + # as empty), returning {location => result}. Both the runtime pipeline and + # the testing adapters go through here so they cannot drift. + def validate(sources) + @contracts.to_h { |location, contract| [location, contract.call(sources.fetch(location, {}))] } + end + + # Constructs the frozen request value from validated location hashes; + # per-request work is construction only. + def build_value(validated_by_location) + locations = @location_shapes.to_h do |name, shape| + [name, shape.build(validated_by_location.fetch(name))] + end + @container_class.new(**locations) + end + + private + + def enforce_declaration_guards + disallow_optional_path_params + @contracts.each_value { |schema| disallow_reserved_keys(schema.info[:keys]) } + end + + # Resolves and guards the location a rules block attaches to: it must be + # named, declared, and not already ruled. + def ruleable_location_block(location) + unless location + raise ArgumentError, + "rules on a request_schema action must name a location — e.g. rules(:body) { ... }" + end + + location_block = @location_blocks[location] + unless location_block + raise ArgumentError, + "rules(#{location.inspect}) — no #{location} location declared " \ + "(declared: #{@location_blocks.keys.map(&:inspect).join(", ")})" + end + if @ruled_locations.include?(location) + raise ArgumentError, + "rules(#{location.inspect}) already defined — each location takes one rules block" + end + + location_block + end + + def disallow_optional_path_params + path_schema = @contracts[:path] + return unless path_schema + + optional_keys = path_schema.info[:keys].reject { |_key, meta| meta[:required] }.keys + return if optional_keys.empty? + + raise ArgumentError, + "optional(#{optional_keys.map(&:inspect).join(", ")}) in path location — " \ + "OpenAPI path parameters are always required" + end + + def disallow_reserved_keys(keys_info) + keys_info.each do |key, meta| + if RESERVED_KEYS.include?(key) + raise ArgumentError, + "schema key #{key.inspect} is reserved by ActionFigure request values " \ + "(#{RESERVED_KEYS.map(&:inspect).join(", ")})" + end + + nested = meta[:keys] || RequestSchema.member_keys(meta) + disallow_reserved_keys(nested) if nested + end + end + end +end diff --git a/lib/action_figure/testing/minitest.rb b/lib/action_figure/testing/minitest.rb index 63ee97b..86379d9 100644 --- a/lib/action_figure/testing/minitest.rb +++ b/lib/action_figure/testing/minitest.rb @@ -28,22 +28,24 @@ def self.define_status_assertions(name, status) Testing.statuses.each { |name, status| define_status_assertions(name, status) } - def assert_valid_params(action_class, params, msg = nil) - result = action_contract(action_class).call(params) - message = msg || "Expected params to be valid, but got errors: #{result.errors.to_h.inspect}" + # params_schema actions take the params hash; request_schema actions take + # locations as keywords: assert_valid_params(action, query: {...}, body: {...}). + def assert_valid_params(action_class, params = nil, msg = nil, **locations) + result = Testing.check(action_class, check_input(params, locations)) + message = msg || "Expected params to be valid, but got errors: #{result.errors.inspect}" assert result.success?, message end - def assert_invalid_params(action_class, params, on: nil, msg: nil) - result = action_contract(action_class).call(params) + def assert_invalid_params(action_class, params = nil, on: nil, msg: nil, **locations) + result = Testing.check(action_class, check_input(params, locations)) if on.nil? message = msg || "Expected params to be invalid, but the contract accepted them" - assert result.failure?, message + refute result.success?, message else - errored = result.errors.to_h.key?(on) + errored = result.errors.key?(on) message = msg || "Expected params to be invalid on #{on.inspect}, " \ - "but errors were: #{result.errors.to_h.inspect}" + "but errors were: #{result.errors.inspect}" assert errored, message end end @@ -71,6 +73,27 @@ def refute_action_json(result, fragment, msg = nil) private + # A positional params hash and location keywords are mutually exclusive; + # rejecting the mix here keeps typo'd keywords (om: for on:) from being + # silently dropped, and rejecting neither keeps a forgotten params hash + # from vacuously validating {}. + def check_input(params, locations) + if params && locations.any? + raise ArgumentError, + "pass either a params hash or location keywords, not both — " \ + "got both #{params.inspect} and #{locations.keys.map(&:inspect).join(", ")}" + end + return params if params + + if locations.empty? + raise ArgumentError, + "no params given — pass a params hash (params_schema) or " \ + "location keywords (request_schema)" + end + + locations + end + def json_subset?(actual, expected) return false unless actual.is_a?(Hash) @@ -87,10 +110,6 @@ def json_value_match?(actual, expected) end end - def action_contract(action_class) - ActionFigure::Testing.contract_for(action_class) - end - def assert_status(expected, result, msg) flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash) diff --git a/lib/action_figure/testing/rspec.rb b/lib/action_figure/testing/rspec.rb index c526c4f..056ac0f 100644 --- a/lib/action_figure/testing/rspec.rb +++ b/lib/action_figure/testing/rspec.rb @@ -61,15 +61,17 @@ def self.define_status_matcher(name, status) end end - # Asserts an action class's params_schema/rules accept the given params. - # Subject is the action class, not a result hash: + # Asserts an action class's schema/rules accept the given params. Subject is + # the action class, not a result hash. params_schema actions take the params + # hash; request_schema actions take locations: # # expect(Users::Create).to accept_params(email: "jane@example.com") + # expect(Projects::Update).to accept_params(query: { id: 1 }, body: { name: "x" }) ::RSpec::Matchers.define :accept_params do |params| - match { |action_class| ActionFigure::Testing.contract_for(action_class).call(params).success? } + match { |action_class| ActionFigure::Testing.check(action_class, params).success? } failure_message do |action_class| - errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h + errors = ActionFigure::Testing.check(action_class, params).errors "expected #{action_class} to accept params, but got errors: #{errors.inspect}" end @@ -86,14 +88,14 @@ def self.define_status_matcher(name, status) chain(:with_error_on) { |field| @field = field } match do |action_class| - errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h + errors = ActionFigure::Testing.check(action_class, params).errors next false if errors.empty? @field.nil? || errors.key?(@field) end failure_message do |action_class| - errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h + errors = ActionFigure::Testing.check(action_class, params).errors if @field "expected #{action_class} to reject params with an error on #{@field.inspect}, " \ "but errors were: #{errors.inspect}" diff --git a/lib/action_figure/testing/statuses.rb b/lib/action_figure/testing/statuses.rb index 225a69e..3aac4bc 100644 --- a/lib/action_figure/testing/statuses.rb +++ b/lib/action_figure/testing/statuses.rb @@ -33,6 +33,52 @@ def self.contract_for(action_class) "#{action_class} defines no params_schema, so it has no contract to validate against" end + # Uniform contract-check result consumed by both adapters: +success?+ plus a + # flat errors hash (merged across locations for request_schema actions). + class Check + attr_reader :errors + + def initialize(success:, errors:) + @success = success + @errors = errors + end + + def success? + @success + end + end + + # Validates +input+ against an action's schema, whichever kind it declares: + # request_schema actions take locations ({query: {...}, body: {...}}, omitted + # locations validating as empty — matching runtime); params_schema actions + # take the params hash. + def self.check(action_class, input) + request_schema = action_class.respond_to?(:request_schema) && action_class.request_schema + return check_locations(request_schema, input) if request_schema + + result = contract_for(action_class).call(input) + Check.new(success: result.success?, errors: result.errors.to_h) + end + + def self.check_locations(request_schema, locations_input) + disallow_unknown_locations(request_schema, locations_input) + + results = request_schema.validate(locations_input) + Check.new(success: results.each_value.all?(&:success?), + errors: RequestSchema.merge_errors(results.values)) + end + private_class_method :check_locations + + def self.disallow_unknown_locations(request_schema, locations_input) + unknown = locations_input.keys - request_schema.contracts.keys + return if unknown.empty? + + raise ArgumentError, + "unknown location(s) #{unknown.map(&:inspect).join(", ")} — declared locations: " \ + "#{request_schema.contracts.keys.map(&:inspect).join(", ")}" + end + private_class_method :disallow_unknown_locations + # Patches whichever adapters are loaded with one status's assertion/matcher. # Called by ActionFigure.register_error so a status registered after the # adapters have loaded still gets its assert_/refute_/be_ helpers. diff --git a/sig/action_figure.rbs b/sig/action_figure.rbs index 368c40b..b3342c0 100644 --- a/sig/action_figure.rbs +++ b/sig/action_figure.rbs @@ -19,6 +19,15 @@ module ActionFigure extend FormatRegistry extend ErrorRegistry + # Duck-type stand-in for a Rails (ActionDispatch) request, for invoking + # request_schema actions from tests and consoles. + class RequestStub + attr_reader path_parameters: Hash[untyped, untyped] + attr_reader query_parameters: Hash[untyped, untyped] + attr_reader request_parameters: Hash[untyped, untyped] + end + + def self.request: (?path: Hash[untyped, untyped], ?query: Hash[untyped, untyped], ?body: Hash[untyped, untyped]) -> RequestStub def self.[]: (?Symbol format) -> Module def self.included: (Module base) -> void def self.register_formatter: (**Module formatters) -> void @@ -91,6 +100,42 @@ module ActionFigure def NoContent: () -> ActionFigure::response end + # Compiled form of a request_schema declaration: per-location coercing + # contracts plus typed value construction. Built once at class load. + class RequestSchema + type location = :path | :query | :body + + LOCATIONS: Array[location] + + # Method names reserved by the generated request values. + RESERVED_KEYS: Array[Symbol] + + # Nested keys of an array-of-hashes member, nil for arrays of primitives. + def self.member_keys: (Hash[Symbol, untyped] meta) -> Hash[Symbol, untyped]? + + # Flattens per-location validation results into one errors hash; + # same-named keys across locations concatenate their messages. + def self.merge_errors: (Array[untyped] results) -> Hash[Symbol, untyped] + + attr_reader contracts: Hash[location, untyped] + + def initialize: () { () -> void } -> void + def attach_rules: (location location) { () -> void } -> void + def validate: (Hash[location, Hash[untyped, untyped]] sources) -> Hash[location, untyped] + def build_value: (Hash[location, Hash[Symbol, untyped]] validated_by_location) -> untyped + + # Instance behavior of the generated location value classes: tracks which + # keys the client actually sent (absent vs. explicit nil). + module ValueBehavior + def self.unwrap: (untyped value) -> untyped + + def given?: (Symbol key) -> bool + def given_keys: () -> Array[Symbol] + def to_h: () -> Hash[Symbol, untyped] + def deconstruct_keys: (Array[Symbol]? requested) -> Hash[Symbol, untyped] + end + end + # Validation pipeline and DSL mixed into action classes module Core # Cross-parameter rule helpers for dry-validation contracts @@ -101,16 +146,39 @@ module ActionFigure def all_rule: (*Symbol fields, String message) -> void end + # One factory for every validation contract class (params_schema contracts + # and request_schema location contracts). + def self.build_contract_class: (Proc schema_block, Proc? rules_block) -> Class + # Class-level DSL extended into action classes module ClassMethods def params_schema: () { () -> void } -> void - def rules: () { () -> void } -> void + + # Block form declares and compiles the schema; no-args form returns the + # compiled ActionFigure::RequestSchema (or nil). + def request_schema: () -> RequestSchema? + | () { () -> void } -> RequestSchema + + # Bare block for params_schema actions; request_schema actions name a + # location: rules(:body) { ... }. + def rules: (?RequestSchema::location? location) { () -> void } -> void def entry_point: (Symbol name) -> void def entry_point_name: () -> Symbol? def api_version: (?untyped value) -> untyped def contract: () -> untyped end + # The request_schema half of the validation pipeline, included into Core. + module RequestValidation + # Maps each schema location to the request reader that supplies it. + LOCATION_SOURCES: Hash[RequestSchema::location, Symbol] + + # Messages a request-like object must answer to be accepted as request:. + REQUEST_DUCK_TYPE: Array[Symbol] + end + + include RequestValidation + def entry_point_name: () -> Symbol? def contract: () -> untyped def validated_call: (**untyped kwargs) -> ActionFigure::response @@ -181,6 +249,19 @@ module ActionFigure # Resolves an action class's validation contract (RSpec adapter helper). def self.contract_for: (untyped action_class) -> untyped + # Uniform contract-check result consumed by both adapters. + class Check + attr_reader errors: Hash[Symbol, untyped] + + def initialize: (success: bool, errors: Hash[Symbol, untyped]) -> void + def success?: () -> bool + end + + # Validates input against an action's schema, whichever kind it declares: + # request_schema actions take locations ({query: {...}, body: {...}}); + # params_schema actions take the params hash. + def self.check: (untyped action_class, untyped input) -> Check + # Defines assert_/refute_ and be_ helpers for a dynamically registered status. def self.define_error_helper: (Symbol name, Symbol status) -> void @@ -217,8 +298,11 @@ module ActionFigure def refute_Locked: (ActionFigure::response result, ?String? msg) -> void def refute_UnavailableForLegalReasons: (ActionFigure::response result, ?String? msg) -> void - def assert_valid_params: (untyped action_class, untyped params, ?String? msg) -> void - def assert_invalid_params: (untyped action_class, untyped params, ?on: Symbol?, ?msg: String?) -> void + # params_schema actions take a positional params hash; request_schema + # actions take locations as keywords (query: {...}, body: {...}). + # Omitting both, or mixing them, raises ArgumentError. + def assert_valid_params: (untyped action_class, ?untyped? params, ?String? msg, **Hash[untyped, untyped] locations) -> void + def assert_invalid_params: (untyped action_class, ?untyped? params, ?on: Symbol?, ?msg: String?, **Hash[untyped, untyped] locations) -> void def assert_action_json: (ActionFigure::response result, Hash[Symbol, untyped] fragment, ?String? msg) -> void def refute_action_json: (ActionFigure::response result, Hash[Symbol, untyped] fragment, ?String? msg) -> void diff --git a/test/action_figure/request_schema_test.rb b/test/action_figure/request_schema_test.rb new file mode 100644 index 0000000..9e8a24d --- /dev/null +++ b/test/action_figure/request_schema_test.rb @@ -0,0 +1,924 @@ +# frozen_string_literal: true + +require "test_helper" + +# --- Declaration --- + +class RequestSchemaDeclarationTest < Minitest::Test + def test_locations_compile_to_coercing_contracts + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + path { required(:id).filled(:integer) } + query { required(:workspace_id).filled(:integer) } + body { required(:name).filled(:string) } + end + end + + assert_equal %i[path query body], action.request_schema.contracts.keys + + result = action.request_schema.contracts[:query].call(workspace_id: "42") + assert_predicate result, :success? + assert_equal({ workspace_id: 42 }, result.to_h) + end + + def test_arrays_of_primitives_compile_and_coerce + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:ids).array(:integer) } + end + + def create(request:) + Ok(resource: { ids: request.body.ids }) + end + end + + result = action.create(request: ActionFigure.request(body: { ids: %w[1 2] })) + + assert_equal :ok, result[:status] + assert_equal [1, 2], result[:json][:data][:ids] + end + + def test_blockless_array_of_hashes_compiles_and_stays_plain + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:tags).array(:hash) } + end + + def create(request:) + Ok(resource: { tags: request.body.tags }) + end + end + + result = action.create(request: ActionFigure.request(body: { tags: [{ "name" => "a" }] })) + + assert_equal :ok, result[:status] + end +end + +# --- Invocation with request: --- + +class RequestSchemaInvocationTest < Minitest::Test + def build_action + Class.new do + include ActionFigure[:jsend] + + request_schema do + path { required(:id).filled(:integer) } + query { required(:workspace_id).filled(:integer) } + body { required(:name).filled(:string) } + end + + def update(request:, current_user:) + Ok(resource: { + id: request.path.id, + workspace_id: request.query.workspace_id, + name: request.body.name, + current_user: current_user + }) + end + end + end + + def test_locations_validate_against_their_sources_and_coerce + action = build_action + + result = action.update( + request: ActionFigure.request( + path: { id: "7" }, + query: { workspace_id: "42" }, + body: { name: "Roadmap" } + ), + current_user: "alice" + ) + + assert_equal :ok, result[:status] + assert_equal( + { id: 7, workspace_id: 42, name: "Roadmap", current_user: "alice" }, + result[:json][:data] + ) + end + + def test_validation_failures_render_unprocessable_content_with_flat_errors + action = build_action + + result = action.update( + request: ActionFigure.request( + path: { id: "7" }, + query: { workspace_id: "42" }, + body: { name: "" } + ), + current_user: "alice" + ) + + assert_equal :unprocessable_content, result[:status] + assert_equal ["must be filled"], result[:json][:data][:name] + end + + def test_query_location_key_arriving_in_body_is_not_seen + action = build_action + + result = action.update( + request: ActionFigure.request( + path: { id: "7" }, + query: {}, + body: { name: "Roadmap", workspace_id: "42" } + ), + current_user: "alice" + ) + + assert_equal :unprocessable_content, result[:status] + assert_equal ["is missing"], result[:json][:data][:workspace_id] + end + + def test_path_location_failure_renders_not_found + action = build_action + + result = action.update( + request: ActionFigure.request( + path: { id: "abc" }, + query: { workspace_id: "42" }, + body: { name: "Roadmap" } + ), + current_user: "alice" + ) + + assert_equal :not_found, result[:status] + assert_equal ["must be an integer"], result[:json][:data][:id] + end + + def test_mixed_failures_render_not_found + action = build_action + + result = action.update( + request: ActionFigure.request( + path: { id: "abc" }, + query: { workspace_id: "42" }, + body: { name: "" } + ), + current_user: "alice" + ) + + assert_equal :not_found, result[:status] + end + + def test_passing_params_instead_of_request_raises_with_guidance + action = build_action + + error = assert_raises(ArgumentError) do + action.update(params: { name: "Roadmap" }, current_user: "alice") + end + + assert_match(/declares request_schema/, error.message) + assert_match(/ActionFigure\.request/, error.message) + end + + def test_passing_a_plain_hash_as_request_raises_with_guidance + action = build_action + + error = assert_raises(ArgumentError) do + action.update(request: { name: "Roadmap" }, current_user: "alice") + end + + assert_match(/ActionFigure\.request/, error.message) + end + + def test_actions_may_declare_only_the_locations_they_have + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:name).filled(:string) } + end + + def create(request:) + Ok(resource: { name: request.body.name }) + end + end + + result = action.create(request: ActionFigure.request(body: { name: "Roadmap" })) + + assert_equal :ok, result[:status] + assert_equal({ name: "Roadmap" }, result[:json][:data]) + end + + def test_given_distinguishes_absent_from_explicit_nil + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body do + required(:name).filled(:string) + optional(:description).maybe(:string) + end + end + + def update(request:) + Ok(resource: { + description: request.body.description, + description_given: request.body.given?(:description) + }) + end + end + + omitted = action.update(request: ActionFigure.request(body: { name: "x" })) + explicit_nil = action.update(request: ActionFigure.request(body: { name: "x", description: nil })) + + assert_equal({ description: nil, description_given: false }, omitted[:json][:data]) + assert_equal({ description: nil, description_given: true }, explicit_nil[:json][:data]) + end + + def test_pattern_matching_matches_only_given_keys + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body do + required(:name).filled(:string) + optional(:description).maybe(:string) + end + end + + def update(request:) + outcome = + case request.body + in { description: } + { matched: true, description: description } + else + { matched: false } + end + Ok(resource: outcome) + end + end + + omitted = action.update(request: ActionFigure.request(body: { name: "x" })) + explicit_nil = action.update(request: ActionFigure.request(body: { name: "x", description: nil })) + + assert_equal({ matched: false }, omitted[:json][:data]) + assert_equal({ matched: true, description: nil }, explicit_nil[:json][:data]) + end + + def test_to_h_returns_given_keys_only + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body do + required(:name).filled(:string) + optional(:description).maybe(:string) + end + end + + def update(request:) + Ok(resource: request.body.to_h) + end + end + + result = action.update(request: ActionFigure.request(body: { name: "x" })) + + assert_equal({ name: "x" }, result[:json][:data]) + end + + def test_nested_hashes_become_nested_typed_values + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body do + required(:project).hash do + required(:name).filled(:string) + optional(:settings).hash do + optional(:visibility).filled(:string) + end + end + end + end + + def create(request:) + project = request.body.project + Ok(resource: { + name: project.name, + visibility: project.settings.visibility, + visibility_given: project.settings.given?(:visibility) + }) + end + end + + result = action.create( + request: ActionFigure.request( + body: { project: { name: "Roadmap", settings: { visibility: "public" } } } + ) + ) + + assert_equal :ok, result[:status] + assert_equal( + { name: "Roadmap", visibility: "public", visibility_given: true }, + result[:json][:data] + ) + end + + def test_arrays_of_hashes_become_arrays_of_typed_values + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body do + required(:tags).array(:hash) do + required(:label).filled(:string) + end + end + end + + def create(request:) + Ok(resource: { labels: request.body.tags.map(&:label) }) + end + end + + result = action.create( + request: ActionFigure.request(body: { tags: [{ label: "api" }, { label: "public" }] }) + ) + + assert_equal :ok, result[:status] + assert_equal({ labels: %w[api public] }, result[:json][:data]) + end + + def test_blockless_hash_stays_a_plain_hash + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body do + required(:name).filled(:string) + required(:metadata).filled(:hash) + end + end + + def create(request:) + Ok(resource: { theme: request.body.metadata[:theme] }) + end + end + + result = action.create( + request: ActionFigure.request(body: { name: "x", metadata: { theme: "dark" } }) + ) + + assert_equal :ok, result[:status] + assert_equal({ theme: "dark" }, result[:json][:data]) + end + + def test_to_h_returns_plain_hashes_all_the_way_down + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body do + required(:project).hash do + required(:name).filled(:string) + required(:tags).array(:hash) do + required(:label).filled(:string) + end + end + end + end + + def create(request:) + Ok(resource: request.body.to_h) + end + end + + result = action.create( + request: ActionFigure.request( + body: { project: { name: "Roadmap", tags: [{ label: "api" }] } } + ) + ) + + assert_equal( + { project: { name: "Roadmap", tags: [{ label: "api" }] } }, + result[:json][:data] + ) + end + + def test_typoed_key_access_raises_no_method_error + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:name).filled(:string) } + end + + def create(request:) + Ok(resource: { name: request.body.naem }) + end + end + + assert_raises(NoMethodError) do + action.create(request: ActionFigure.request(body: { name: "Roadmap" })) + end + end +end + +# --- Rules --- + +class RequestSchemaRulesTest < Minitest::Test + def build_action + Class.new do + include ActionFigure[:jsend] + + request_schema do + query { required(:workspace_id).filled(:integer) } + body do + optional(:user_id).filled(:integer) + optional(:email).filled(:string) + end + end + + rules(:body) do + exclusive_rule(:user_id, :email, "provide one, not both") + end + + def lookup(request:) + Ok(resource: { user_id: request.body.user_id, email: request.body.email }) + end + end + end + + def test_cross_param_rule_failures_render_unprocessable_content + action = build_action + + result = action.lookup( + request: ActionFigure.request( + query: { workspace_id: "1" }, + body: { user_id: 7, email: "tad@example.com" } + ) + ) + + assert_equal :unprocessable_content, result[:status] + assert_includes result[:json][:data][:user_id], "provide one, not both" + assert_includes result[:json][:data][:email], "provide one, not both" + end + + def test_query_rules_enforce_cross_param_constraints + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + query do + required(:from).filled(:integer) + required(:to).filled(:integer) + end + end + + rules(:query) do + rule(:from, :to) do + key(:from).failure("must be before to") if values[:from] > values[:to] + end + end + + def list(request:) + Ok(resource: { from: request.query.from, to: request.query.to }) + end + end + + invalid = action.list(request: ActionFigure.request(query: { from: "9", to: "3" })) + valid = action.list(request: ActionFigure.request(query: { from: "3", to: "9" })) + + assert_equal :unprocessable_content, invalid[:status] + assert_includes invalid[:json][:data][:from], "must be before to" + assert_equal :ok, valid[:status] + end + + def test_rules_for_an_undeclared_location_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + query { required(:page).filled(:integer) } + end + + rules(:body) do + rule(:page) { key.failure("nope") } + end + end + end + + assert_match(/no body location declared/, error.message) + assert_match(/declared: :query/, error.message) + end + + def test_bare_rules_on_a_request_schema_action_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:name).filled(:string) } + end + + rules do + rule(:name) { key.failure("nope") } + end + end + end + + assert_match(/must name a location/, error.message) + end + + def test_location_rules_on_a_params_schema_action_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + params_schema { required(:name).filled(:string) } + + rules(:body) do + rule(:name) { key.failure("nope") } + end + end + end + + assert_match(/locations are a request_schema concept/, error.message) + end + + def test_location_rules_before_request_schema_points_at_declaration_order + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + rules(:body) do + rule(:name) { key.failure("nope") } + end + + request_schema do + body { required(:name).filled(:string) } + end + end + end + + assert_match(/requires request_schema to be declared first/, error.message) + end + + def test_duplicate_rules_for_a_location_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:name).filled(:string) } + end + + rules(:body) { rule(:name) { key.failure("first") } } + rules(:body) { rule(:name) { key.failure("second") } } + end + end + + assert_match(/rules\(:body\) already defined/, error.message) + end + + def test_path_rules_failures_render_not_found + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + path { required(:id).filled(:integer) } + end + + rules(:path) do + rule(:id) { key.failure("must be positive") unless values[:id].positive? } + end + + def show(request:) + Ok(resource: { id: request.path.id }) + end + end + + result = action.show(request: ActionFigure.request(path: { id: "-1" })) + + assert_equal :not_found, result[:status] + assert_includes result[:json][:data][:id], "must be positive" + end + + def test_rules_pass_when_satisfied + action = build_action + + result = action.lookup( + request: ActionFigure.request(query: { workspace_id: "1" }, body: { user_id: 7 }) + ) + + assert_equal :ok, result[:status] + assert_equal({ user_id: 7, email: nil }, result[:json][:data]) + end +end + +# --- request: duck type --- + +class RequestSchemaDuckTypeTest < Minitest::Test + def test_non_request_argument_raises_naming_actiondispatch + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:name).filled(:string) } + end + + def create(request:) + Ok(resource: request.body.to_h) + end + end + + error = assert_raises(ArgumentError) { action.create(request: { name: "x" }) } + + assert_match(/ActionDispatch/, error.message) + refute_match(%r{Rails/Rack}, error.message) + end +end + +# --- Source reads are lazy per declared location --- + +class RequestSchemaSourceReadsTest < Minitest::Test + def test_undeclared_locations_are_never_read_from_the_request + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + query { required(:q).filled(:string) } + end + + def search(request:) + Ok(resource: request.query.to_h) + end + end + + request = Class.new do + def path_parameters = {} + def query_parameters = { q: "x" } + def request_parameters = raise("body must not be parsed for a query-only schema") + end.new + + result = action.search(request: request) + + assert_equal :ok, result[:status] + assert_equal({ q: "x" }, result[:json][:data]) + end +end + +# --- whiny_extra_params --- + +class RequestSchemaWhinyExtraParamsTest < Minitest::Test + def setup + @original = ActionFigure.configuration.whiny_extra_params + ActionFigure.configuration.whiny_extra_params = true + end + + def teardown + ActionFigure.configuration.whiny_extra_params = @original + end + + def build_action + Class.new do + include ActionFigure[:jsend] + + request_schema do + path { required(:id).filled(:integer) } + body { required(:name).filled(:string) } + end + + def update(request:) + Ok(resource: request.body.to_h) + end + end + end + + def test_whiny_extra_params_rejects_undeclared_keys_in_declared_locations + result = build_action.update( + request: ActionFigure.request(path: { id: "1" }, body: { name: "x", admin: true }) + ) + + assert_equal :unprocessable_content, result[:status] + assert_equal ["is not allowed"], result[:json][:data][:admin] + end + + def test_whiny_extra_params_ignores_router_bookkeeping_in_path + result = build_action.update( + request: ActionFigure.request( + path: { id: "1", controller: "users", action: "update", format: "json" }, + body: { name: "x" } + ) + ) + + assert_equal :ok, result[:status] + end + + def test_extra_keys_pass_silently_when_whiny_is_off + ActionFigure.configuration.whiny_extra_params = false + + result = build_action.update( + request: ActionFigure.request(path: { id: "1" }, body: { name: "x", admin: true }) + ) + + assert_equal :ok, result[:status] + assert_equal({ name: "x" }, result[:json][:data]) + end +end + +# --- Cross-location error merging --- + +class RequestSchemaErrorMergeTest < Minitest::Test + def build_action + Class.new do + include ActionFigure[:jsend] + + request_schema do + query { required(:limit).filled(:integer) } + body { required(:limit).filled(:string) } + end + + def update(request:) + Ok(resource: request.body.to_h) + end + end + end + + def test_same_key_failing_in_two_locations_keeps_both_errors + result = build_action.update( + request: ActionFigure.request(query: { limit: "abc" }, body: {}) + ) + + assert_equal :unprocessable_content, result[:status] + assert_equal ["must be an integer", "is missing"], result[:json][:data][:limit] + end + + def test_nested_errors_merge_across_locations + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + query { required(:filter).hash { required(:mode).filled(:string) } } + body { required(:filter).hash { required(:name).filled(:string) } } + end + + def update(request:) + Ok(resource: request.body.to_h) + end + end + + result = action.update( + request: ActionFigure.request(query: { filter: {} }, body: { filter: {} }) + ) + + assert_equal :unprocessable_content, result[:status] + assert_equal({ mode: ["is missing"], name: ["is missing"] }, result[:json][:data][:filter]) + end +end + +# --- Class-load guards --- + +class RequestSchemaGuardsTest < Minitest::Test + def test_request_schema_after_params_schema_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + params_schema { required(:name).filled(:string) } + request_schema { body { required(:name).filled(:string) } } + end + end + + assert_match(/params_schema/, error.message) + end + + def test_params_schema_after_request_schema_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema { body { required(:name).filled(:string) } } + params_schema { required(:name).filled(:string) } + end + end + + assert_match(/request_schema/, error.message) + end + + def test_optional_key_in_path_location_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + path { optional(:id).filled(:integer) } + end + end + end + + assert_match(/path parameters are always required/, error.message) + end + + def test_reserved_key_name_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:to_h).filled(:string) } + end + end + end + + assert_match(/to_h.*reserved/, error.message) + end + + def test_given_keys_is_a_reserved_key_name + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:given_keys).filled(:string) } + end + end + end + + assert_match(/given_keys.*reserved/, error.message) + end + + def test_nested_reserved_key_name_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + body do + required(:settings).hash do + required(:deconstruct_keys).filled(:string) + end + end + end + end + end + + assert_match(/deconstruct_keys.*reserved/, error.message) + end + + def test_bare_declaration_outside_locations_raises_with_guidance + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + required(:name).filled(:string) + end + end + end + + assert_match(/inside a path, query, or body location/, error.message) + end + + def test_duplicate_location_block_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + body { required(:name).filled(:string) } + body { required(:title).filled(:string) } + end + end + end + + assert_match(/body already declared/, error.message) + end + + def test_blockless_location_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema do + path + query { required(:q).filled(:string) } + end + end + end + + assert_match(/path location requires a block/, error.message) + end + + def test_duplicate_request_schema_raises + error = assert_raises(ArgumentError) do + Class.new do + include ActionFigure[:jsend] + + request_schema { body { required(:name).filled(:string) } } + request_schema { query { required(:page).filled(:integer) } } + end + end + + assert_match(/already defined/, error.message) + end +end diff --git a/test/action_figure/testing/minitest_contract_test.rb b/test/action_figure/testing/minitest_contract_test.rb index 5b8d587..3c7b810 100644 --- a/test/action_figure/testing/minitest_contract_test.rb +++ b/test/action_figure/testing/minitest_contract_test.rb @@ -67,6 +67,29 @@ def test_assert_invalid_params_fails_when_params_are_valid assert_includes error.message, "invalid" end + def test_omitting_params_entirely_raises + action = build_action do + required(:email).filled(:string) + end + + error = assert_raises(ArgumentError) { assert_invalid_params(action) } + assert_match(/no params given/, error.message) + + assert_raises(ArgumentError) { assert_valid_params(action) } + end + + def test_mixing_positional_params_with_keywords_raises + action = build_action do + required(:email).filled(:string) + end + + error = assert_raises(ArgumentError) do + assert_invalid_params(action, { email: "" }, om: :email) + end + + assert_match(/:om/, error.message) + end + def test_contract_helpers_raise_for_actions_without_a_schema action = Class.new do include ActionFigure[:jsend] @@ -78,3 +101,59 @@ def call = Ok(resource: {}) assert_includes error.message, "params_schema" end end + +class MinitestRequestSchemaContractHelpersTest < Minitest::Test + include ActionFigure::Testing::Minitest + + def build_action + Class.new do + include ActionFigure[:jsend] + + request_schema do + query { required(:workspace_id).filled(:integer) } + body { required(:name).filled(:string) } + end + + def create(request:) = Ok(resource: request.body.to_h) + end + end + + def test_assert_valid_params_validates_locations_against_their_contracts + assert_valid_params(build_action, query: { workspace_id: "1" }, body: { name: "Roadmap" }) + end + + def test_assert_invalid_params_scopes_to_a_field_across_locations + assert_invalid_params(build_action, query: { workspace_id: "1" }, body: { name: "" }, on: :name) + end + + def test_omitted_locations_validate_as_empty_matching_runtime + assert_invalid_params(build_action, body: { name: "Roadmap" }, on: :workspace_id) + end + + def test_unknown_location_raises_with_declared_locations + error = assert_raises(ArgumentError) do + assert_valid_params(build_action, headers: { token: "x" }) + end + + assert_match(/unknown location/, error.message) + assert_match(/:query, :body/, error.message) + end + + def test_same_key_errors_in_two_locations_are_both_reported + action = Class.new do + include ActionFigure[:jsend] + + request_schema do + query { required(:limit).filled(:integer) } + body { required(:limit).filled(:string) } + end + + def update(request:) = Ok(resource: request.body.to_h) + end + + check = ActionFigure::Testing.check(action, query: { limit: "abc" }, body: {}) + + refute check.success? + assert_equal ["must be an integer", "is missing"], check.errors[:limit] + end +end diff --git a/test/action_figure/testing/rspec_test.rb b/test/action_figure/testing/rspec_test.rb index 9a1e66a..35744b9 100644 --- a/test/action_figure/testing/rspec_test.rb +++ b/test/action_figure/testing/rspec_test.rb @@ -137,6 +137,35 @@ def call(params:) = Ok(resource: params) end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /:name/) end + def build_request_schema_action + Class.new do + include ActionFigure[:jsend] + + request_schema do + query { required(:workspace_id).filled(:integer) } + body { required(:name).filled(:string) } + end + + def create(request:) = Ok(resource: request.body.to_h) + end + end + + it "accept_params validates locations against their contracts" do + expect(build_request_schema_action) + .to accept_params(query: { workspace_id: "1" }, body: { name: "Roadmap" }) + end + + it "reject_params with_error_on works across locations, omitted locations validating as empty" do + expect(build_request_schema_action) + .to reject_params(body: { name: "Roadmap" }).with_error_on(:workspace_id) + end + + it "contract matchers raise for unknown locations" do + expect do + expect(build_request_schema_action).to accept_params(headers: { token: "x" }) + end.to raise_error(ArgumentError, /unknown location/) + end + it "matches the new built-in Gone status" do expect({ status: :gone }).to be_Gone expect({ status: :ok }).not_to be_Gone