From 3651a9f02d9c98e8329f419a2f61d13c59b142ce Mon Sep 17 00:00:00 2001 From: Tad Thorley Date: Thu, 2 Jul 2026 03:15:21 -0600 Subject: [PATCH] Add central error-status registry and register_error API --- .github/workflows/main.yml | 1 + CHANGELOG.md | 20 ++- Gemfile.lock | 1 + action_figure.gemspec | 1 + docs/custom-formatters.md | 66 ++++---- docs/response-formatters.md | 5 +- docs/status-codes.md | 8 +- docs/testing.md | 8 +- lib/action_figure.rb | 6 +- lib/action_figure/error_registry.rb | 108 +++++++++++++ lib/action_figure/formatter.rb | 17 +- lib/action_figure/formatters/default.rb | 20 +-- lib/action_figure/formatters/jsend.rb | 20 +-- lib/action_figure/formatters/json_api.rb | 21 +-- lib/action_figure/formatters/wrapped.rb | 20 +-- lib/action_figure/testing/minitest.rb | 6 +- lib/action_figure/testing/rspec.rb | 6 +- lib/action_figure/testing/statuses.rb | 41 +++-- sig/action_figure.rbs | 80 +++++++--- test/action_figure/error_generation_test.rb | 105 ++++++++++++ test/action_figure/error_registry_test.rb | 85 ++++++++++ test/action_figure/formatter_test.rb | 4 +- test/action_figure/formatters/default_test.rb | 71 +-------- test/action_figure/formatters/jsend_test.rb | 76 +-------- .../action_figure/formatters/json_api_test.rb | 149 +++++------------- test/action_figure/formatters/wrapped_test.rb | 71 +-------- test/action_figure/testing/minitest_test.rb | 24 ++- test/action_figure/testing/rspec_test.rb | 13 ++ 28 files changed, 574 insertions(+), 479 deletions(-) create mode 100644 lib/action_figure/error_registry.rb create mode 100644 test/action_figure/error_generation_test.rb create mode 100644 test/action_figure/error_registry_test.rb diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 933d9fa..468805d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,6 +20,7 @@ jobs: - '3.2' - '3.3' - '3.4' + - '4.0' steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 706d868..a1a73cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to ActionFigure will be documented in this file. +## [Unreleased] + +### Added + +- **Central error-status registry.** `ActionFigure.error_statuses` lists every error helper (name → Rack status symbol); `ActionFigure.register_error(:BadGateway, :bad_gateway)` adds new ones. Generated helpers live on a registry module included into `ActionFigure::Formatter`, so a registered status is immediately available in **every** formatter, in action classes that were composed **before** the registration, and as `assert_*`/`refute_*`/`be_*` test helpers. +- New built-in error statuses: **`Gone`** (410), **`Locked`** (423), and **`UnavailableForLegalReasons`** (451). +- `ActionFigure.status_code_for(status_symbol)` — resolves a Rack status symbol to its numeric code (accepts both `:unprocessable_content` and `:unprocessable_entity` on every supported Rack version). +- `register_error` validates its status symbol against Rack's status table and rejects helper names reserved by the formatter contract, so typos fail at boot instead of on the first rendered error. + +### Changed + +- **Formatter contract shrank from 8 methods to 4**: `Ok`, `Created`, `Accepted`, and the new `error_response(errors:, status:)`. Named error helpers (`NotFound`, `Conflict`, …) are generated from the registry and delegate to `error_response`; a hand-defined named helper on a formatter still wins. +- The gem now declares a runtime dependency on **rack** (>= 2.2), used to resolve and validate status codes. + +### Removed + +- `ActionFigure::Testing::STATUSES` (unreleased) — replaced by the live `ActionFigure::Testing.statuses`, which always reflects the current registry. + ## [0.7.0] - 2026-06-29 ### Added @@ -12,7 +30,7 @@ All notable changes to ActionFigure will be documented in this file. ### Changed -- Status helpers for both adapters are now generated from a single **`ActionFigure::Testing::STATUSES`** registry (including **`NoContent`**), so the Minitest and RSpec lists can no longer drift. Replaces the RSpec-only **`ActionFigure::Testing::RSpec::MATCHERS`** constant. +- Status helpers for both adapters are now generated from a single shared status map (including **`NoContent`**), so the Minitest and RSpec lists can no longer drift. Replaces the RSpec-only **`ActionFigure::Testing::RSpec::MATCHERS`** constant. (Superseded by the live **`ActionFigure::Testing.statuses`** — see Unreleased.) - Status assertions/matchers now fail with a clear "expected an ActionFigure result hash" message when given a non-Hash, instead of raising **`NoMethodError`**. ### Known limitation diff --git a/Gemfile.lock b/Gemfile.lock index 9c4e8f9..c0c1cce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ PATH action_figure (0.6.2) concurrent-ruby (>= 1.0) dry-validation (~> 1.10) + rack (>= 2.2) GEM remote: https://rubygems.org/ diff --git a/action_figure.gemspec b/action_figure.gemspec index b9526f0..089dfc7 100644 --- a/action_figure.gemspec +++ b/action_figure.gemspec @@ -32,4 +32,5 @@ Gem::Specification.new do |spec| spec.add_dependency "concurrent-ruby", ">= 1.0" spec.add_dependency "dry-validation", "~> 1.10" + spec.add_dependency "rack", ">= 2.2" end diff --git a/docs/custom-formatters.md b/docs/custom-formatters.md index d4aa7cc..ea1afb0 100644 --- a/docs/custom-formatters.md +++ b/docs/custom-formatters.md @@ -11,24 +11,23 @@ A formatter is a module that includes `ActionFigure::Formatter` and defines meth Including `ActionFigure::Formatter` gives you: - A default `NoContent` implementation that returns `{ status: :no_content }`. -- A contract enforced at registration time: your module **must** define all 8 required methods. +- Named error helpers for every registered error status (`NotFound`, `Conflict`, `Gone`, etc.), carried by a live registry module included into `ActionFigure::Formatter`. Each generated helper delegates to `error_response`. +- A contract enforced at registration time: your module **must** define all 4 required methods. The required methods are: -| Method | Purpose | -|------------------------|----------------------------------------------| -| `Ok` | Successful retrieval or generic success | -| `Created` | Resource was created | -| `Accepted` | Request accepted for background processing | -| `UnprocessableContent` | Validation or schema rule failure | -| `NotFound` | Resource not found | -| `Forbidden` | Authorization failure | -| `Conflict` | Resource state conflict or duplicate | -| `PaymentRequired` | Business billing or quota constraint | +| Method | Purpose | +|------------------------|------------------------------------------------------------------| +| `Ok` | Successful retrieval or generic success | +| `Created` | Resource was created | +| `Accepted` | Request accepted for background processing | +| `error_response` | Low-level failure renderer called by every named error helper | `NoContent` is provided by the base module and does not need to be defined, but you can override it if your format requires a different shape. -Each method receives keyword arguments and must return a hash. The exact keywords depend on the outcome -- success methods receive `resource:` (and optionally `meta:`), while failure methods receive `errors:`. +Named error helpers (`NotFound`, `Conflict`, `Gone`, `Locked`, `UnavailableForLegalReasons`, and any additional statuses you register) are generated from the error registry and all delegate to `error_response(errors:, status:)`. A formatter may still hand-define a specific named helper to override the generated one — a method defined on the formatter itself sits ahead of the generated helpers in the ancestor chain and wins. + +`error_response` receives the keyword arguments `errors:` (an error hash) and `status:` (a Rack status symbol) and must return a hash. ## Building a Custom Formatter @@ -56,29 +55,13 @@ module WrappedFormatter { json: body, status: :accepted } end - def UnprocessableContent(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :unprocessable_content } - end - - def NotFound(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :not_found } - end - - def Forbidden(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :forbidden } - end - - def Conflict(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :conflict } - end - - def PaymentRequired(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :payment_required } + def error_response(errors:, status:) + { json: { data: nil, errors: errors, status: "error" }, status: status } end end ``` -All 8 required methods are defined. Success methods accept `resource:` and optionally `meta:`, while failure methods accept `errors:`. The `NoContent` method is inherited from the base `ActionFigure::Formatter` module and returns `{ status: :no_content }` with no JSON body -- override it if your format requires a different shape. +All 4 required methods are defined. Success methods accept `resource:` and optionally `meta:`. `error_response` accepts `errors:` and `status:` and is called by every generated named error helper. The `NoContent` method is inherited from the base `ActionFigure::Formatter` module and returns `{ status: :no_content }` with no JSON body -- override it if your format requires a different shape. ## Registering Your Formatter @@ -115,9 +98,11 @@ Registration is not just bookkeeping -- ActionFigure validates every formatter m ```ruby ActionFigure::Formatter::REQUIRED_METHODS -# => [:Ok, :Created, :Accepted, :UnprocessableContent, :NotFound, :Forbidden, :Conflict, :PaymentRequired] +# => [:Ok, :Created, :Accepted, :error_response] ``` +**Migration note (pre-0.7 formatters):** If you have a formatter written against the pre-0.7 eight-method contract that does not define `error_response`, registration will now **fail** — `REQUIRED_METHODS` requires `error_response`. Add an `error_response(errors:, status:)` method to your formatter before upgrading to 0.7. + If any required method is missing, registration raises an `ArgumentError` that lists exactly which methods are absent: ```ruby @@ -130,8 +115,7 @@ module IncompleteFormatter end ActionFigure.register_formatter(incomplete: IncompleteFormatter) -# => ArgumentError: IncompleteFormatter is missing formatter methods: Created, Accepted, -# UnprocessableContent, NotFound, Forbidden, Conflict, PaymentRequired +# => ArgumentError: IncompleteFormatter is missing formatter methods: Created, Accepted, error_response ``` Validation is **atomic** when registering multiple formatters at once. If any single module in the batch fails validation, none of them are registered -- this ensures your registry always remains in a consistent state. @@ -185,3 +169,17 @@ end ``` Setting `config.format` makes every action use your formatter unless an individual action explicitly includes a different one. Per-action includes always take precedence over the global default. + +## Registering Additional Error Statuses + +ActionFigure ships with eight built-in error statuses (see [HTTP 4xx Status Codes](status-codes.md)). You can register additional ones with `ActionFigure.register_error`: + +```ruby +ActionFigure.register_error(:BadGateway, :bad_gateway) +``` + +The first argument is the helper name (a Symbol or String) that will be available in action classes (`BadGateway(errors:)`). The second argument must be a valid Rack status symbol — `register_error` validates it against `Rack::Utils::SYMBOL_TO_STATUS_CODE` and raises `ArgumentError` immediately for a symbol no supported Rack version knows, so a typo fails at boot rather than on the first error rendered in production. + +Registration is **add-only**: you cannot remove or override a built-in status. Names reserved by the formatter contract (`Ok`, `Created`, `Accepted`, `NoContent`, `error_response`) are rejected. + +Registration can happen at any time. Generated helpers live on a single registry module included into `ActionFigure::Formatter`, so a newly registered status is immediately available to every formatter and every action class — including classes that ran `include ActionFigure[...]` before the registration — and `register_error` also patches any already-loaded Minitest assertions and RSpec matchers. An initializer is still the natural home for registrations, but nothing breaks if one runs late. diff --git a/docs/response-formatters.md b/docs/response-formatters.md index 995a259..dcf6589 100644 --- a/docs/response-formatters.md +++ b/docs/response-formatters.md @@ -47,7 +47,7 @@ end ## Response Helpers -Every formatter implements the same nine response helpers. Eight return a hash with `:json` and `:status` keys. `NoContent` returns only `:status`. +Every formatter implements the same twelve response helpers. Eleven return a hash with `:json` and `:status` keys. `NoContent` returns only `:status`. | Helper | HTTP Status | When to Use | |---------------------------------|--------------------------|--------------------------------------------------| @@ -59,7 +59,10 @@ Every formatter implements the same nine response helpers. Eight return a hash w | `Forbidden(errors:)` | `403 Forbidden` | Authorization failure | | `NotFound(errors:)` | `404 Not Found` | Resource not found | | `Conflict(errors:)` | `409 Conflict` | Resource state conflict or duplicate | +| `Gone(errors:)` | `410 Gone` | Resource permanently deleted (not just 404) | | `UnprocessableContent(errors:)` | `422 Unprocessable Content` | Validation failures | +| `Locked(errors:)` | `423 Locked` | Resource locked by another process | +| `UnavailableForLegalReasons(errors:)` | `451 Unavailable For Legal Reasons` | Resource censored for legal/regional reasons | `NoContent` is shared across all formatters and is defined in the base `Formatter` module. It returns `{ status: :no_content }` with no JSON body. diff --git a/docs/status-codes.md b/docs/status-codes.md index 5c50e86..403c5ad 100644 --- a/docs/status-codes.md +++ b/docs/status-codes.md @@ -14,7 +14,7 @@ Rows in **bold** are status codes with built-in formatter methods — action cla | 407 | Proxy Auth Required | Perimeter | Infrastructure | Similar to 401, but for a proxy server. | | 408 | Request Timeout | Perimeter | Server/Nginx | The client took too long to send the request. | | **409** | **Conflict** | **Domain** | **Action Class** | **Resource already exists, or the state is in conflict.** | -| 410 | Gone | Domain | Action Class | The resource is permanently deleted (not just 404). | +| **410** | **Gone** | **Domain** | **Action Class** | **The resource is permanently deleted (not just 404).** | | 411 | Length Required | Perimeter | Server/Rack | The request didn't specify a Content-Length. | | 412 | Precondition Failed | Perimeter | Controller/Rack | If-Match headers don't match (usually for caching). | | 413 | Payload Too Large | Perimeter | Server/Nginx | The request body is bigger than the server allows. | @@ -25,11 +25,13 @@ Rows in **bold** are status codes with built-in formatter methods — action cla | 418 | I'm a teapot | Domain | Action Class | An IETF April Fools joke (rarely used in production). | | 421 | Misdirected Request | Perimeter | Infrastructure | The server can't produce a response for this connection. | | **422** | **Unprocessable Content** | **Domain** | **Action Class** | **Semantic errors (validation, business rules).** | -| 423 | Locked | Domain | Action Class | The resource is being accessed by another process. | +| **423** | **Locked** | **Domain** | **Action Class** | **The resource is being accessed by another process.** | | 424 | Failed Dependency | Domain | Action Class | The request failed due to a failure of a previous request. | | 425 | Too Early | Perimeter | Server/Rack | The server is unwilling to process a request that might be replayed. | | 426 | Upgrade Required | Perimeter | Server/Rack | The client must switch to a different protocol (e.g., TLS). | | 428 | Precondition Required | Perimeter | Controller/Rack | The server requires the request to be conditional. | | 429 | Too Many Requests | Perimeter | Rack::Attack | Infrastructure-level rate limiting (IP-based, etc.). | | 431 | Request Header Fields Too Large | Perimeter | Server/Rack | HTTP headers are too large. | -| 451 | Unavailable For Legal Reasons | Domain | Action Class | Resource censored/blocked for legal/regional reasons. | +| **451** | **Unavailable For Legal Reasons** | **Domain** | **Action Class** | **Resource censored/blocked for legal/regional reasons.** | + +Any other status — including 5xx codes such as 502 Bad Gateway — can be added with `ActionFigure.register_error(:BadGateway, :bad_gateway)`. The status symbol is validated against Rack's status table at registration, so a typo raises `ArgumentError` at boot. 5xx codes are a deliberate opt-out from the domain/perimeter split and are not built in by default. diff --git a/docs/testing.md b/docs/testing.md index 68c92db..3085a7d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -35,8 +35,11 @@ end | `assert_Forbidden(result)` | `:forbidden` | | `assert_Conflict(result)` | `:conflict` | | `assert_PaymentRequired(result)` | `:payment_required` | +| `assert_Gone(result)` | `:gone` | +| `assert_Locked(result)` | `:locked` | +| `assert_UnavailableForLegalReasons(result)` | `:unavailable_for_legal_reasons` | -Each status assertion has a negated counterpart — **`refute_Ok`**, **`refute_Created`**, … — that passes when the status is anything *other* than the named one. +Each status assertion has a negated counterpart — **`refute_Ok`**, **`refute_Created`**, … — that passes when the status is anything *other* than the named one. Statuses added with `ActionFigure.register_error` get matching `assert_*`/`refute_*` helpers automatically, whether registered before or after this adapter loads. These helpers compare **only `result[:status]`** against the Rack-style symbol Rails uses in **`render`** — they **do not** assert on **`[:json]`** keys, payloads, or error message text. Combine them with assertions on **`result[:json]`** (or matchers on the body your formatter produces) whenever shape matters. @@ -92,6 +95,9 @@ require "action_figure/testing/rspec" | `be_Forbidden` | `:forbidden` | | `be_Conflict` | `:conflict` | | `be_PaymentRequired` | `:payment_required` | +| `be_Gone` | `:gone` | +| `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` | | `reject_params(params)` | action class's contract rejects `params` (chain `.with_error_on(:field)`) | diff --git a/lib/action_figure.rb b/lib/action_figure.rb index 755be7d..564e4f7 100644 --- a/lib/action_figure.rb +++ b/lib/action_figure.rb @@ -3,6 +3,7 @@ require_relative "action_figure/version" require_relative "action_figure/configuration" require_relative "action_figure/format_registry" +require_relative "action_figure/error_registry" require_relative "action_figure/formatter" require_relative "action_figure/core" require_relative "action_figure/formatters/jsend" @@ -12,8 +13,11 @@ # ActionFigure provides explicit, purpose-driven operation classes for Rails controller actions. module ActionFigure + @format_modules = Concurrent::Map.new + extend Configuration extend FormatRegistry + extend ErrorRegistry class IndeterminateEntryPointError < StandardError; end @@ -78,7 +82,7 @@ def self.included(base) private_class_method :new_format_module def self.format_modules - @format_modules ||= Concurrent::Map.new + @format_modules end private_class_method :format_modules end diff --git a/lib/action_figure/error_registry.rb b/lib/action_figure/error_registry.rb new file mode 100644 index 0000000..39b9837 --- /dev/null +++ b/lib/action_figure/error_registry.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "concurrent/map" +require "rack/utils" + +module ActionFigure + # Central registry mapping error-helper names (e.g. +:NotFound+) to the Rack + # status symbol they render (e.g. +:not_found+). Single source of truth for + # formatter error helpers and the Minitest/RSpec status assertions. + # + # Extended into ActionFigure, so the public API is ActionFigure.error_statuses + # and ActionFigure.register_error. + module ErrorRegistry + # Built-in error statuses. Domain-owned 4xx only — no 5xx ships built-in. + DEFAULTS = { + UnprocessableContent: :unprocessable_content, + NotFound: :not_found, + Forbidden: :forbidden, + Conflict: :conflict, + PaymentRequired: :payment_required, + Gone: :gone, + Locked: :locked, + UnavailableForLegalReasons: :unavailable_for_legal_reasons + }.freeze + + # Live module holding one generated helper per registered error status. + # ActionFigure::Formatter includes it, so the helpers reach every formatter + # module and every composed format module through method lookup — including + # classes that included a format module before a late +register_error+ call. + # A helper hand-defined on a formatter shadows the generated one, because + # the formatter sits ahead of this module in the ancestor chain. + module Helpers; end + + def self.define_helper(name, status_symbol) + Helpers.define_method(name) do |errors:| + error_response(errors: errors, status: status_symbol) + end + end + + DEFAULTS.each { |name, status| define_helper(name, status) } + + # Populates the registry when ErrorRegistry is extended (at gem load, + # single-threaded), so no lazy initialization races with reader threads. + def self.extended(base) + registry = Concurrent::Map.new + DEFAULTS.each { |name, status| registry[name] = status } + base.instance_variable_set(:@error_status_registry, registry) + end + + # Statuses Rack itself doesn't map on every supported version: + # Rack < 3.1 knows 422 only as :unprocessable_entity, Rack >= 3.1 only as + # :unprocessable_content. Accept both everywhere. + STATUS_CODE_FALLBACKS = { unprocessable_content: 422, unprocessable_entity: 422 }.freeze + + # A plain-Hash copy of the current registry (built-ins plus any registered). + def error_statuses + error_status_registry.each_pair.to_h + end + + # Resolves a Rack-style status symbol to its numeric HTTP status code, + # raising ArgumentError for symbols no supported Rack version knows. + # Single resolution point for formatters and register_error validation. + def status_code_for(status_symbol) + Rack::Utils::SYMBOL_TO_STATUS_CODE[status_symbol] || + STATUS_CODE_FALLBACKS[status_symbol] || + raise(ArgumentError, "#{status_symbol.inspect} is not a known HTTP status symbol") + end + + # Registers an additional error status. Add-only: does not remove or override. + # Defines the generated helper on the live Helpers module (so it appears on + # every formatter and already-included action class immediately) and patches + # already-loaded test adapters. + def register_error(name, status_symbol) + name = name.to_sym + status_symbol = status_symbol.to_sym + validate_registration!(name, status_symbol) + + existing = error_status_registry.put_if_absent(name, status_symbol) + if existing && existing != status_symbol + raise ArgumentError, + "Error status #{name.inspect} is already registered as #{existing.inspect}; " \ + "register_error is add-only and cannot override it" + end + return name if existing + + ErrorRegistry.define_helper(name, status_symbol) + ActionFigure::Testing.define_error_helper(name, status_symbol) if defined?(ActionFigure::Testing) + name + end + + private + + attr_reader :error_status_registry + + # Rejects reserved helper names and unknown status symbols up front, so a + # bad registration fails at boot instead of at render time. + def validate_registration!(name, status_symbol) + reserved = Formatter::REQUIRED_METHODS + [:NoContent] + if reserved.include?(name) + raise ArgumentError, + "#{name.inspect} is reserved by the formatter contract and cannot be " \ + "registered as an error status" + end + + status_code_for(status_symbol) + end + end +end diff --git a/lib/action_figure/formatter.rb b/lib/action_figure/formatter.rb index 3830b60..213df1b 100644 --- a/lib/action_figure/formatter.rb +++ b/lib/action_figure/formatter.rb @@ -1,13 +1,20 @@ # frozen_string_literal: true +require_relative "error_registry" + module ActionFigure # Base module for ActionFigure response formatters. - # Include this in your formatter module to get a NoContent default - # and to signal that your module implements the formatter interface. + # Include this in your formatter module to get a NoContent default, the + # generated named error helpers (NotFound, Conflict, and every registered + # status), and to signal that your module implements the formatter interface. module Formatter - # Response helper names every formatter must define (+NoContent+ lives on +Formatter+, not required here). - # Update every built-in formatter when you extend this list; +register_formatter+ validates against it at load time. - REQUIRED_METHODS = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden Conflict PaymentRequired].freeze + include ErrorRegistry::Helpers + + # Structural methods every formatter must define: the three success helpers plus + # the single error_response. Named error helpers are generated from + # ActionFigure.error_statuses onto ErrorRegistry::Helpers (included here), + # so they are NOT a per-formatter obligation. + REQUIRED_METHODS = %i[Ok Created Accepted error_response].freeze def NoContent { status: :no_content } diff --git a/lib/action_figure/formatters/default.rb b/lib/action_figure/formatters/default.rb index 850f09b..73c511b 100644 --- a/lib/action_figure/formatters/default.rb +++ b/lib/action_figure/formatters/default.rb @@ -25,24 +25,8 @@ def Accepted(resource: nil, meta: nil) { json: body, status: :accepted } end - def UnprocessableContent(errors:) - { json: { errors: errors }, status: :unprocessable_content } - end - - def NotFound(errors:) - { json: { errors: errors }, status: :not_found } - end - - def Forbidden(errors:) - { json: { errors: errors }, status: :forbidden } - end - - def Conflict(errors:) - { json: { errors: errors }, status: :conflict } - end - - def PaymentRequired(errors:) - { json: { errors: errors }, status: :payment_required } + def error_response(errors:, status:) + { json: { errors: errors }, status: status } end end end diff --git a/lib/action_figure/formatters/jsend.rb b/lib/action_figure/formatters/jsend.rb index 5f81b81..4f292bf 100644 --- a/lib/action_figure/formatters/jsend.rb +++ b/lib/action_figure/formatters/jsend.rb @@ -25,24 +25,8 @@ def Accepted(resource: nil, meta: nil) { json: body, status: :accepted } end - def UnprocessableContent(errors:) - { json: { status: "fail", data: errors }, status: :unprocessable_content } - end - - def NotFound(errors:) - { json: { status: "fail", data: errors }, status: :not_found } - end - - def Forbidden(errors:) - { json: { status: "fail", data: errors }, status: :forbidden } - end - - def Conflict(errors:) - { json: { status: "fail", data: errors }, status: :conflict } - end - - def PaymentRequired(errors:) - { json: { status: "fail", data: errors }, status: :payment_required } + def error_response(errors:, status:) + { json: { status: "fail", data: errors }, status: status } end end end diff --git a/lib/action_figure/formatters/json_api.rb b/lib/action_figure/formatters/json_api.rb index e594515..85fc2cb 100644 --- a/lib/action_figure/formatters/json_api.rb +++ b/lib/action_figure/formatters/json_api.rb @@ -26,24 +26,9 @@ def Accepted(resource: nil, meta: nil) { json: body, status: :accepted } end - def UnprocessableContent(errors:) - { json: { errors: convert_errors(errors, "422") }, status: :unprocessable_content } - end - - def NotFound(errors:) - { json: { errors: convert_errors(errors, "404") }, status: :not_found } - end - - def Forbidden(errors:) - { json: { errors: convert_errors(errors, "403") }, status: :forbidden } - end - - def Conflict(errors:) - { json: { errors: convert_errors(errors, "409") }, status: :conflict } - end - - def PaymentRequired(errors:) - { json: { errors: convert_errors(errors, "402") }, status: :payment_required } + def error_response(errors:, status:) + code = ActionFigure.status_code_for(status).to_s + { json: { errors: convert_errors(errors, code) }, status: status } end private diff --git a/lib/action_figure/formatters/wrapped.rb b/lib/action_figure/formatters/wrapped.rb index 2518517..f926a52 100644 --- a/lib/action_figure/formatters/wrapped.rb +++ b/lib/action_figure/formatters/wrapped.rb @@ -25,24 +25,8 @@ def Accepted(resource: nil, meta: nil) { json: body, status: :accepted } end - def UnprocessableContent(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :unprocessable_content } - end - - def NotFound(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :not_found } - end - - def Forbidden(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :forbidden } - end - - def Conflict(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :conflict } - end - - def PaymentRequired(errors:) - { json: { data: nil, errors: errors, status: "error" }, status: :payment_required } + def error_response(errors:, status:) + { json: { data: nil, errors: errors, status: "error" }, status: status } end end end diff --git a/lib/action_figure/testing/minitest.rb b/lib/action_figure/testing/minitest.rb index 2ee878e..63ee97b 100644 --- a/lib/action_figure/testing/minitest.rb +++ b/lib/action_figure/testing/minitest.rb @@ -13,10 +13,10 @@ module Testing # include ActionFigure::Testing::Minitest # end # - # The status helpers are generated from ActionFigure::Testing::STATUSES so the + # The status helpers are generated from ActionFigure::Testing.statuses so the # Minitest and RSpec adapters never drift. module Minitest - STATUSES.each do |name, status| + def self.define_status_assertions(name, status) define_method(:"assert_#{name}") do |result, msg = nil| assert_status(status, result, msg) end @@ -26,6 +26,8 @@ module Minitest end end + 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}" diff --git a/lib/action_figure/testing/rspec.rb b/lib/action_figure/testing/rspec.rb index 1d460ba..c526c4f 100644 --- a/lib/action_figure/testing/rspec.rb +++ b/lib/action_figure/testing/rspec.rb @@ -18,9 +18,7 @@ module Testing # end # end module RSpec - # Generated from ActionFigure::Testing::STATUSES so the RSpec and Minitest - # adapters never drift. - STATUSES.each do |name, status| + def self.define_status_matcher(name, status) ::RSpec::Matchers.define :"be_#{name}" do match { |result| result.is_a?(Hash) && result[:status] == status } failure_message do |result| @@ -34,6 +32,8 @@ module RSpec end end + Testing.statuses.each { |name, status| define_status_matcher(name, status) } + # Asserts against +result[:json]+ using +a_hash_including+ (nested matchers allowed). ::RSpec::Matchers.define :have_action_json do |expected_fragment| include ::RSpec::Matchers diff --git a/lib/action_figure/testing/statuses.rb b/lib/action_figure/testing/statuses.rb index c6a984e..225a69e 100644 --- a/lib/action_figure/testing/statuses.rb +++ b/lib/action_figure/testing/statuses.rb @@ -5,10 +5,22 @@ module ActionFigure # Helpers shared by the Minitest and RSpec testing adapters. module Testing - # Converts a helper name (+:UnprocessableContent+) to its Rack-style status - # symbol (+:unprocessable_content+). - def self.status_symbol(name) - name.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym + # Success/no-body statuses with bespoke formatter bodies. These are not part of + # the error registry (their bodies differ per outcome, not just per status). + SUCCESS_STATUSES = { + Ok: :ok, + Created: :created, + Accepted: :accepted, + NoContent: :no_content + }.freeze + + # Live full name→status map driving both adapters: success statuses plus + # every error status registered so far (built-in and user-registered). + # Each adapter iterates this at its own load time, so an adapter loaded + # after a +register_error+ call still sees the full registry; registrations + # made after an adapter loads are patched in via +define_error_helper+. + def self.statuses + SUCCESS_STATUSES.merge(ActionFigure.error_statuses) end # Resolves an action class's validation contract, raising a clear error when @@ -21,17 +33,14 @@ def self.contract_for(action_class) "#{action_class} defines no params_schema, so it has no contract to validate against" end - # Single source of truth for the status-helper names exposed by the testing - # adapters, mapping each helper name to the status symbol Rails uses in - # +render+. Both the Minitest assertions (+assert_Ok+, +refute_Ok+, ...) and - # the RSpec matchers (+be_Ok+, ...) are generated from this map so the two - # adapters never drift. - # - # +NoContent+ lives on +Formatter+ rather than +Formatter::REQUIRED_METHODS+, - # so it is added here explicitly. - STATUSES = ActionFigure::Formatter::REQUIRED_METHODS - .to_h { |name| [name, status_symbol(name)] } - .merge(NoContent: :no_content) - .freeze + # 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. + # +const_defined?(..., false)+ checks only this namespace — a bare + # +defined?(Minitest)+ would find the top-level framework constant. + def self.define_error_helper(name, status) + Minitest.define_status_assertions(name, status) if const_defined?(:Minitest, false) + RSpec.define_status_matcher(name, status) if const_defined?(:RSpec, false) + end end end diff --git a/sig/action_figure.rbs b/sig/action_figure.rbs index 6b09529..de443d7 100644 --- a/sig/action_figure.rbs +++ b/sig/action_figure.rbs @@ -20,11 +20,15 @@ module ActionFigure extend Configuration extend FormatRegistry + extend ErrorRegistry def self.[]: (?Symbol format) -> Module def self.included: (Module base) -> void def self.register_formatter: (**Module formatters) -> void def self.clear_format_module_cache: (Symbol name) -> void + def self.error_statuses: () -> Hash[Symbol, Symbol] + def self.register_error: (Symbol | String name, Symbol | String status_symbol) -> Symbol + def self.status_code_for: (Symbol status_symbol) -> Integer # Provides global configuration via ActionFigure.configure module Configuration @@ -55,8 +59,36 @@ module ActionFigure def fetch: (Symbol name) -> Module end + # Central registry of error-helper name → status symbol mappings. + module ErrorRegistry + DEFAULTS: Hash[Symbol, Symbol] + STATUS_CODE_FALLBACKS: Hash[Symbol, Integer] + + # Live module carrying one generated error helper per registered status. + # Helpers for user-registered statuses are defined at runtime; the + # built-in ones are declared explicitly so the signatures stay visible. + module Helpers + def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response + def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response + def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response + def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response + def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response + def Gone: (errors: ActionFigure::error_hash) -> ActionFigure::response + def Locked: (errors: ActionFigure::error_hash) -> ActionFigure::response + def UnavailableForLegalReasons: (errors: ActionFigure::error_hash) -> ActionFigure::response + end + + def self.define_helper: (Symbol name, Symbol status_symbol) -> void + + def error_statuses: () -> Hash[Symbol, Symbol] + def register_error: (Symbol | String name, Symbol | String status_symbol) -> Symbol + def status_code_for: (Symbol status_symbol) -> Integer + end + # Base module for response formatters module Formatter + include ErrorRegistry::Helpers + REQUIRED_METHODS: Array[Symbol] def NoContent: () -> ActionFigure::response @@ -102,11 +134,7 @@ module ActionFigure def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response def Accepted: (?resource: untyped?, ?meta: untyped?) -> ActionFigure::response - def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response - def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response - def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response - def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response - def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response + def error_response: (errors: ActionFigure::error_hash, status: Symbol) -> ActionFigure::response end # JSend-formatted responses @@ -116,11 +144,7 @@ module ActionFigure def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response def Accepted: (?resource: untyped?, ?meta: untyped?) -> ActionFigure::response - def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response - def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response - def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response - def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response - def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response + def error_response: (errors: ActionFigure::error_hash, status: Symbol) -> ActionFigure::response end # JSON:API-formatted responses @@ -130,11 +154,7 @@ module ActionFigure def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response def Accepted: (?resource: untyped?, ?meta: untyped?) -> ActionFigure::response - def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response - def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response - def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response - def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response - def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response + def error_response: (errors: ActionFigure::error_hash, status: Symbol) -> ActionFigure::response # Simple resource serialization for JSON:API class Resource @@ -149,28 +169,31 @@ module ActionFigure def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response def Accepted: (?resource: untyped?, ?meta: untyped?) -> ActionFigure::response - def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response - def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response - def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response - def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response - def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response + def error_response: (errors: ActionFigure::error_hash, status: Symbol) -> ActionFigure::response end end module Testing - # Single source of truth mapping status-helper names to status symbols. - STATUSES: Hash[Symbol, Symbol] + # Success-only statuses (Ok, Created, Accepted, NoContent). + SUCCESS_STATUSES: Hash[Symbol, Symbol] - def self.status_symbol: (Symbol | String name) -> Symbol + # Live map of status-helper names to status symbols: success statuses plus + # every registered error status. + def self.statuses: () -> Hash[Symbol, Symbol] # Resolves an action class's validation contract (RSpec adapter helper). def self.contract_for: (untyped action_class) -> untyped + # Defines assert_/refute_ and be_ helpers for a dynamically registered status. + def self.define_error_helper: (Symbol name, Symbol status) -> void + # Minitest assertions for action response results. # - # assert_* / refute_* status helpers are generated from STATUSES at load time; - # they are declared explicitly here so the signatures stay visible. + # assert_* / refute_* status helpers are generated from Testing.statuses at + # load time; they are declared explicitly here so the signatures stay visible. module Minitest + def self.define_status_assertions: (Symbol name, Symbol status) -> void + def assert_Ok: (ActionFigure::response result, ?String? msg) -> void def assert_Created: (ActionFigure::response result, ?String? msg) -> void def assert_Accepted: (ActionFigure::response result, ?String? msg) -> void @@ -180,6 +203,9 @@ module ActionFigure def assert_Forbidden: (ActionFigure::response result, ?String? msg) -> void def assert_Conflict: (ActionFigure::response result, ?String? msg) -> void def assert_PaymentRequired: (ActionFigure::response result, ?String? msg) -> void + def assert_Gone: (ActionFigure::response result, ?String? msg) -> void + def assert_Locked: (ActionFigure::response result, ?String? msg) -> void + def assert_UnavailableForLegalReasons: (ActionFigure::response result, ?String? msg) -> void def refute_Ok: (ActionFigure::response result, ?String? msg) -> void def refute_Created: (ActionFigure::response result, ?String? msg) -> void @@ -190,6 +216,9 @@ module ActionFigure def refute_Forbidden: (ActionFigure::response result, ?String? msg) -> void def refute_Conflict: (ActionFigure::response result, ?String? msg) -> void def refute_PaymentRequired: (ActionFigure::response result, ?String? msg) -> void + def refute_Gone: (ActionFigure::response result, ?String? msg) -> void + 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 @@ -202,6 +231,7 @@ module ActionFigure # and the contract matchers accept_params / reject_params are registered # globally via RSpec::Matchers.define and so are not module methods. module RSpec + def self.define_status_matcher: (Symbol name, Symbol status) -> void end end end diff --git a/test/action_figure/error_generation_test.rb b/test/action_figure/error_generation_test.rb new file mode 100644 index 0000000..aac17eb --- /dev/null +++ b/test/action_figure/error_generation_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "test_helper" +require "action_figure/testing/minitest" + +class ErrorGenerationTest < Minitest::Test + # A throwaway consumer that mixes in a composed format module, mirroring how an + # action class gains the response helpers. + def build_consumer(format) + Class.new do + include ActionFigure[format] + end.new + end + + def test_named_error_helpers_are_generated_for_each_builtin_format + %i[default jsend jsonapi wrapped].each do |format| + consumer = build_consumer(format) + assert consumer.respond_to?(:NotFound), "#{format} should generate NotFound" + assert consumer.respond_to?(:Gone), "#{format} should generate Gone" + assert consumer.respond_to?(:Locked), "#{format} should generate Locked" + end + end + + def test_generated_helper_routes_through_error_response_with_correct_status + consumer = build_consumer(:default) + result = consumer.NotFound(errors: { base: ["x"] }) + assert_equal :not_found, result[:status] + assert_equal({ errors: { base: ["x"] } }, result[:json]) + end + + def test_generated_helper_for_new_builtin_status + consumer = build_consumer(:wrapped) + result = consumer.Gone(errors: { base: ["gone"] }) + assert_equal :gone, result[:status] + assert_equal({ data: nil, errors: { base: ["gone"] }, status: "error" }, result[:json]) + end + + def test_hand_defined_named_helper_is_not_clobbered_by_generation + custom = Module.new do + include ActionFigure::Formatter + + def Ok(resource:) = { json: { data: resource }, status: :ok } + def Created(resource:) = { json: { data: resource }, status: :created } + def Accepted(resource: nil) = { json: { data: resource }, status: :accepted } + def error_response(errors:, status:) = { json: { errors: errors }, status: status } + + # Hand-written, intentionally distinctive body. + def NotFound(errors:) = { json: { custom: errors }, status: :not_found } + end + + ActionFigure.register_formatter(custom_notfound: custom) + consumer = Class.new { include ActionFigure[:custom_notfound] }.new + result = consumer.NotFound(errors: { base: ["x"] }) + + assert_equal({ custom: { base: ["x"] } }, result[:json], + "generation must not overwrite a hand-defined NotFound") + end +end + +class RegisterErrorRoundTripTest < Minitest::Test + include ActionFigure::Testing::Minitest + + def test_registering_bad_gateway_lights_up_every_formatter + ActionFigure.register_error(:BadGateway, :bad_gateway) + %i[default jsend jsonapi wrapped].each do |format| + consumer = Class.new { include ActionFigure[format] }.new + result = consumer.BadGateway(errors: { base: ["upstream down"] }) + assert_equal :bad_gateway, result[:status], "#{format} should route BadGateway" + end + end + + def test_registering_bad_gateway_defines_the_minitest_assertion + ActionFigure.register_error(:BadGateway, :bad_gateway) + assert_respond_to self, :assert_BadGateway + assert_BadGateway({ status: :bad_gateway }) + end + + def test_late_registration_reaches_already_included_action_classes + consumer = Class.new { include ActionFigure[:default] }.new + refute_respond_to consumer, :ServiceUnavailable + ActionFigure.register_error(:ServiceUnavailable, :service_unavailable) + result = consumer.ServiceUnavailable(errors: { base: ["down"] }) + assert_equal :service_unavailable, result[:status] + assert_equal({ errors: { base: ["down"] } }, result[:json]) + end + + def test_direct_extension_of_a_formatter_module_exposes_named_helpers + formatter = Object.new.extend(ActionFigure::Formatters::Jsend) + result = formatter.NotFound(errors: { base: ["x"] }) + assert_equal :not_found, result[:status] + assert_equal({ status: "fail", data: { base: ["x"] } }, result[:json]) + end + + def test_testing_statuses_reflects_late_registrations + ActionFigure.register_error(:NotImplemented, :not_implemented) + assert_equal :not_implemented, ActionFigure::Testing.statuses[:NotImplemented] + assert_equal :ok, ActionFigure::Testing.statuses[:Ok] + end + + def test_format_module_identity_is_stable_across_registration + before = ActionFigure[:jsend] + ActionFigure.register_error(:GatewayTimeout, :gateway_timeout) + assert_same before, ActionFigure[:jsend] + end +end diff --git a/test/action_figure/error_registry_test.rb b/test/action_figure/error_registry_test.rb new file mode 100644 index 0000000..ce73a90 --- /dev/null +++ b/test/action_figure/error_registry_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "test_helper" + +class ErrorRegistryTest < Minitest::Test + def test_defaults_include_the_five_original_statuses + statuses = ActionFigure.error_statuses + assert_equal :unprocessable_content, statuses[:UnprocessableContent] + assert_equal :not_found, statuses[:NotFound] + assert_equal :forbidden, statuses[:Forbidden] + assert_equal :conflict, statuses[:Conflict] + assert_equal :payment_required, statuses[:PaymentRequired] + end + + def test_defaults_include_the_new_builtin_4xx_statuses + statuses = ActionFigure.error_statuses + assert_equal :gone, statuses[:Gone] + assert_equal :locked, statuses[:Locked] + assert_equal :unavailable_for_legal_reasons, statuses[:UnavailableForLegalReasons] + end + + def test_error_statuses_returns_a_copy_not_the_internal_store + ActionFigure.error_statuses[:Bogus] = :bogus + refute ActionFigure.error_statuses.key?(:Bogus), + "mutating the returned hash must not affect the registry" + end + + def test_register_error_adds_an_entry + ActionFigure.register_error(:TooManyRequests, :too_many_requests) + assert_equal :too_many_requests, ActionFigure.error_statuses[:TooManyRequests] + end + + def test_register_error_coerces_strings_to_symbols + ActionFigure.register_error("MisdirectedRequest", "misdirected_request") + assert_equal :misdirected_request, ActionFigure.error_statuses[:MisdirectedRequest] + end + + def test_register_error_returns_the_name_symbol + assert_equal :UpgradeRequired, ActionFigure.register_error(:UpgradeRequired, :upgrade_required) + end + + def test_register_error_raises_when_overriding_existing_with_different_status + err = assert_raises(ArgumentError) do + ActionFigure.register_error(:NotFound, :too_many_requests) + end + assert_match "already registered", err.message + assert_equal :not_found, ActionFigure.error_statuses[:NotFound] + end + + def test_register_error_idempotent_same_value_does_not_raise + result = ActionFigure.register_error(:NotFound, :not_found) + assert_equal :NotFound, result + assert_equal :not_found, ActionFigure.error_statuses[:NotFound] + end + + def test_register_error_rejects_unknown_http_status_symbols_at_registration + err = assert_raises(ArgumentError) do + ActionFigure.register_error(:BogusStatus, :not_a_real_status) + end + assert_match "not_a_real_status", err.message + refute ActionFigure.error_statuses.key?(:BogusStatus), + "a rejected status must not land in the registry" + end + + def test_status_code_for_resolves_rack_status_symbols + assert_equal 404, ActionFigure.status_code_for(:not_found) + assert_equal 422, ActionFigure.status_code_for(:unprocessable_content) + end + + def test_status_code_for_raises_for_unknown_symbols + err = assert_raises(ArgumentError) { ActionFigure.status_code_for(:nope) } + assert_match "nope", err.message + end + + def test_register_error_rejects_names_reserved_by_the_formatter_contract + %i[Ok Created Accepted NoContent error_response].each do |reserved| + err = assert_raises(ArgumentError, "#{reserved} must be rejected") do + ActionFigure.register_error(reserved, :bad_gateway) + end + assert_match "reserved", err.message + refute ActionFigure.error_statuses.key?(reserved), + "#{reserved} must not land in the registry" + end + end +end diff --git a/test/action_figure/formatter_test.rb b/test/action_figure/formatter_test.rb index 83c9df5..c4ed239 100644 --- a/test/action_figure/formatter_test.rb +++ b/test/action_figure/formatter_test.rb @@ -3,8 +3,8 @@ require "test_helper" class FormatterModuleTest < Minitest::Test - def test_required_methods_lists_expected_symbols - expected = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden Conflict PaymentRequired] + def test_required_methods_lists_the_structural_contract + expected = %i[Ok Created Accepted error_response] assert_equal expected, ActionFigure::Formatter::REQUIRED_METHODS end diff --git a/test/action_figure/formatters/default_test.rb b/test/action_figure/formatters/default_test.rb index 4dcff6d..835e1a3 100644 --- a/test/action_figure/formatters/default_test.rb +++ b/test/action_figure/formatters/default_test.rb @@ -118,75 +118,18 @@ def test_no_content_has_no_json_body refute result.key?(:json) end - # --- UnprocessableContent --- + # --- error_response --- - def test_unprocessable_content_returns_422 + def test_error_response_wraps_errors_under_errors_key formatter = Object.new.extend(ActionFigure::Formatters::Default) - result = formatter.UnprocessableContent(errors: { name: ["can't be blank"] }) - assert_equal :unprocessable_content, result[:status] + result = formatter.error_response(errors: { base: ["nope"] }, status: :not_found) + assert_equal({ errors: { base: ["nope"] } }, result[:json]) end - def test_unprocessable_content_wraps_errors_under_errors_key + def test_error_response_uses_the_given_status formatter = Object.new.extend(ActionFigure::Formatters::Default) - errors = { name: ["can't be blank"] } - result = formatter.UnprocessableContent(errors:) - assert_equal({ errors: errors }, result[:json]) - end - - # --- NotFound --- - - def test_not_found_returns_404 - formatter = Object.new.extend(ActionFigure::Formatters::Default) - result = formatter.NotFound(errors: { base: ["not found"] }) - assert_equal :not_found, result[:status] - end - - def test_not_found_wraps_errors_under_errors_key - formatter = Object.new.extend(ActionFigure::Formatters::Default) - result = formatter.NotFound(errors: { base: ["not found"] }) - assert_equal({ errors: { base: ["not found"] } }, result[:json]) - end - - # --- Forbidden --- - - def test_forbidden_returns_403 - formatter = Object.new.extend(ActionFigure::Formatters::Default) - result = formatter.Forbidden(errors: { base: ["not authorized"] }) - assert_equal :forbidden, result[:status] - end - - def test_forbidden_wraps_errors_under_errors_key - formatter = Object.new.extend(ActionFigure::Formatters::Default) - result = formatter.Forbidden(errors: { base: ["not authorized"] }) - assert_equal({ errors: { base: ["not authorized"] } }, result[:json]) - end - - # --- Conflict --- - - def test_conflict_returns_409 - formatter = Object.new.extend(ActionFigure::Formatters::Default) - result = formatter.Conflict(errors: { base: ["already exists"] }) - assert_equal :conflict, result[:status] - end - - def test_conflict_wraps_errors_under_errors_key - formatter = Object.new.extend(ActionFigure::Formatters::Default) - result = formatter.Conflict(errors: { base: ["already exists"] }) - assert_equal({ errors: { base: ["already exists"] } }, result[:json]) - end - - # --- PaymentRequired --- - - def test_payment_required_returns_402 - formatter = Object.new.extend(ActionFigure::Formatters::Default) - result = formatter.PaymentRequired(errors: { base: ["subscription overdue"] }) - assert_equal :payment_required, result[:status] - end - - def test_payment_required_wraps_errors_under_errors_key - formatter = Object.new.extend(ActionFigure::Formatters::Default) - result = formatter.PaymentRequired(errors: { base: ["subscription overdue"] }) - assert_equal({ errors: { base: ["subscription overdue"] } }, result[:json]) + assert_equal :conflict, formatter.error_response(errors: {}, status: :conflict)[:status] + assert_equal :gone, formatter.error_response(errors: {}, status: :gone)[:status] end end diff --git a/test/action_figure/formatters/jsend_test.rb b/test/action_figure/formatters/jsend_test.rb index d19ffc6..af4dda9 100644 --- a/test/action_figure/formatters/jsend_test.rb +++ b/test/action_figure/formatters/jsend_test.rb @@ -123,80 +123,18 @@ def test_no_content_has_no_json_body refute result.key?(:json) end - # --- UnprocessableContent --- + # --- error_response --- - def test_unprocessable_content_returns_422 + def test_error_response_wraps_errors_in_data_with_fail_status formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - result = formatter.UnprocessableContent(errors: { name: ["can't be blank"] }) - assert_equal :unprocessable_content, result[:status] + result = formatter.error_response(errors: { base: ["nope"] }, status: :not_found) + assert_equal({ status: "fail", data: { base: ["nope"] } }, result[:json]) end - def test_unprocessable_content_wraps_errors_in_data_with_fail_status + def test_error_response_uses_the_given_status formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - errors = { name: ["can't be blank"] } - result = formatter.UnprocessableContent(errors:) - assert_equal "fail", result[:json][:status] - assert_equal errors, result[:json][:data] - end - - # --- NotFound --- - - def test_not_found_returns_404 - formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - result = formatter.NotFound(errors: { base: ["not found"] }) - assert_equal :not_found, result[:status] - end - - def test_not_found_wraps_errors_in_data_with_fail_status - formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - result = formatter.NotFound(errors: { base: ["not found"] }) - assert_equal "fail", result[:json][:status] - assert_equal({ base: ["not found"] }, result[:json][:data]) - end - - # --- Forbidden --- - - def test_forbidden_returns_403 - formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - result = formatter.Forbidden(errors: { base: ["not authorized"] }) - assert_equal :forbidden, result[:status] - end - - def test_forbidden_wraps_errors_in_data_with_fail_status - formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - result = formatter.Forbidden(errors: { base: ["not authorized"] }) - assert_equal "fail", result[:json][:status] - assert_equal({ base: ["not authorized"] }, result[:json][:data]) - end - - # --- Conflict --- - - def test_conflict_returns_409 - formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - result = formatter.Conflict(errors: { base: ["already exists"] }) - assert_equal :conflict, result[:status] - end - - def test_conflict_wraps_errors_in_data_with_fail_status - formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - result = formatter.Conflict(errors: { base: ["already exists"] }) - assert_equal "fail", result[:json][:status] - assert_equal({ base: ["already exists"] }, result[:json][:data]) - end - - # --- PaymentRequired --- - - def test_payment_required_returns_402 - formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - result = formatter.PaymentRequired(errors: { base: ["subscription overdue"] }) - assert_equal :payment_required, result[:status] - end - - def test_payment_required_wraps_errors_in_data_with_fail_status - formatter = Object.new.extend(ActionFigure::Formatters::Jsend) - result = formatter.PaymentRequired(errors: { base: ["subscription overdue"] }) - assert_equal "fail", result[:json][:status] - assert_equal({ base: ["subscription overdue"] }, result[:json][:data]) + assert_equal :conflict, formatter.error_response(errors: {}, status: :conflict)[:status] + assert_equal :gone, formatter.error_response(errors: {}, status: :gone)[:status] end end diff --git a/test/action_figure/formatters/json_api_test.rb b/test/action_figure/formatters/json_api_test.rb index e64d522..70b53ad 100644 --- a/test/action_figure/formatters/json_api_test.rb +++ b/test/action_figure/formatters/json_api_test.rb @@ -127,130 +127,55 @@ def test_no_content_has_no_json_body refute result.key?(:json) end - # --- UnprocessableContent --- + # --- error_response --- - def test_unprocessable_content_returns_422 + def test_error_response_converts_errors_with_derived_status_code formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.UnprocessableContent(errors: { name: ["can't be blank"] }) - assert_equal :unprocessable_content, result[:status] - end - - def test_unprocessable_content_converts_error_to_jsonapi_object - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.UnprocessableContent(errors: { name: ["can't be blank"] }) - error = result[:json][:errors].first - assert_equal "422", error[:status] - assert_equal "can't be blank", error[:detail] - assert_equal "/data/attributes/name", error[:source][:pointer] - end - - def test_unprocessable_content_multiple_messages_produce_multiple_errors - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.UnprocessableContent(errors: { name: ["can't be blank", "is too short"] }) - assert_equal 2, result[:json][:errors].length - assert_equal "can't be blank", result[:json][:errors][0][:detail] - assert_equal "is too short", result[:json][:errors][1][:detail] - end - - def test_unprocessable_content_multiple_fields_produce_multiple_errors - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.UnprocessableContent(errors: { name: ["can't be blank"], email: ["is invalid"] }) - pointers = result[:json][:errors].map { _1[:source][:pointer] } - assert_includes pointers, "/data/attributes/name" - assert_includes pointers, "/data/attributes/email" - end - - def test_unprocessable_content_nested_errors_produce_nested_pointers - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.UnprocessableContent(errors: { user: { name: ["is missing"], email: ["is missing"] } }) - pointers = result[:json][:errors].map { _1[:source][:pointer] } - assert_includes pointers, "/data/attributes/user/name" - assert_includes pointers, "/data/attributes/user/email" - end - - def test_unprocessable_content_deeply_nested_errors - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.UnprocessableContent(errors: { user: { address: { city: ["is missing"] } } }) - error = result[:json][:errors].first - assert_equal "/data/attributes/user/address/city", error[:source][:pointer] - assert_equal "is missing", error[:detail] - end - - # --- NotFound --- - - def test_not_found_returns_404 - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.NotFound(errors: { base: ["not found"] }) + result = formatter.error_response(errors: { name: ["bad"] }, status: :not_found) assert_equal :not_found, result[:status] + first = result[:json][:errors].first + assert_equal "404", first[:status] + assert_equal "/data/attributes/name", first[:source][:pointer] end - def test_not_found_error_has_404_status_string + def test_error_response_derives_code_for_registered_style_statuses formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.NotFound(errors: { base: ["not found"] }) - assert_equal "404", result[:json][:errors].first[:status] + result = formatter.error_response(errors: { base: ["gone"] }, status: :gone) + assert_equal "410", result[:json][:errors].first[:status] end - def test_not_found_pointer_derived_from_error_key + def test_error_response_base_error_produces_document_level_data_pointer formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.NotFound(errors: { record: ["not found"] }) - assert_equal "/data/attributes/record", result[:json][:errors].first[:source][:pointer] - end - - def test_not_found_base_error_produces_data_pointer - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.NotFound(errors: { base: ["not found"] }) + result = formatter.error_response(errors: { base: ["conflict"] }, status: :conflict) assert_equal "/data", result[:json][:errors].first[:source][:pointer] end - # --- Forbidden --- - - def test_forbidden_returns_403 - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.Forbidden(errors: { base: ["not authorized"] }) - assert_equal :forbidden, result[:status] - end - - def test_forbidden_error_has_403_status_string - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.Forbidden(errors: { base: ["not authorized"] }) - error = result[:json][:errors].first - assert_equal "403", error[:status] - assert_equal "not authorized", error[:detail] - assert_equal "/data", error[:source][:pointer] - end - - # --- Conflict --- - - def test_conflict_returns_409 - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.Conflict(errors: { base: ["already exists"] }) - assert_equal :conflict, result[:status] - end - - def test_conflict_error_has_409_status_string - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.Conflict(errors: { base: ["already exists"] }) - error = result[:json][:errors].first - assert_equal "409", error[:status] - assert_equal "already exists", error[:detail] - assert_equal "/data", error[:source][:pointer] - end - - # --- PaymentRequired --- - - def test_payment_required_returns_402 - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.PaymentRequired(errors: { base: ["subscription overdue"] }) - assert_equal :payment_required, result[:status] - end - - def test_payment_required_error_has_402_status_string - formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) - result = formatter.PaymentRequired(errors: { base: ["subscription overdue"] }) - error = result[:json][:errors].first - assert_equal "402", error[:status] - assert_equal "subscription overdue", error[:detail] - assert_equal "/data", error[:source][:pointer] + def test_error_response_nested_errors_hash_produces_deep_pointer + formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) + result = formatter.error_response( + errors: { address: { city: ["is required"] } }, + status: :unprocessable_content + ) + errors = result[:json][:errors] + assert_equal 1, errors.length + assert_equal "/data/attributes/address/city", errors.first[:source][:pointer] + assert_equal "422", errors.first[:status] + assert_equal "is required", errors.first[:detail] + end + + def test_error_response_multiple_messages_produces_multiple_entries + formatter = Object.new.extend(ActionFigure::Formatters::JsonApi) + result = formatter.error_response( + errors: { name: ["too short", "has invalid chars"] }, + status: :unprocessable_content + ) + errors = result[:json][:errors] + assert_equal 2, errors.length + assert_equal "/data/attributes/name", errors.first[:source][:pointer] + assert_equal "/data/attributes/name", errors.last[:source][:pointer] + assert_equal "too short", errors.first[:detail] + assert_equal "has invalid chars", errors.last[:detail] + assert_equal "422", errors.first[:status] end end diff --git a/test/action_figure/formatters/wrapped_test.rb b/test/action_figure/formatters/wrapped_test.rb index 2a1be79..c4ec148 100644 --- a/test/action_figure/formatters/wrapped_test.rb +++ b/test/action_figure/formatters/wrapped_test.rb @@ -121,75 +121,18 @@ def test_no_content_has_no_json_body refute result.key?(:json) end - # --- UnprocessableContent --- + # --- error_response --- - def test_unprocessable_content_returns_422 + def test_error_response_wraps_errors_in_envelope formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - result = formatter.UnprocessableContent(errors: { name: ["can't be blank"] }) - assert_equal :unprocessable_content, result[:status] + result = formatter.error_response(errors: { base: ["nope"] }, status: :not_found) + assert_equal({ data: nil, errors: { base: ["nope"] }, status: "error" }, result[:json]) end - def test_unprocessable_content_wraps_errors_in_envelope + def test_error_response_uses_the_given_status formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - errors = { name: ["can't be blank"] } - result = formatter.UnprocessableContent(errors:) - assert_equal({ data: nil, errors: errors, status: "error" }, result[:json]) - end - - # --- NotFound --- - - def test_not_found_returns_404 - formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - result = formatter.NotFound(errors: { base: ["not found"] }) - assert_equal :not_found, result[:status] - end - - def test_not_found_wraps_errors_in_envelope - formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - result = formatter.NotFound(errors: { base: ["not found"] }) - assert_equal({ data: nil, errors: { base: ["not found"] }, status: "error" }, result[:json]) - end - - # --- Forbidden --- - - def test_forbidden_returns_403 - formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - result = formatter.Forbidden(errors: { base: ["not authorized"] }) - assert_equal :forbidden, result[:status] - end - - def test_forbidden_wraps_errors_in_envelope - formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - result = formatter.Forbidden(errors: { base: ["not authorized"] }) - assert_equal({ data: nil, errors: { base: ["not authorized"] }, status: "error" }, result[:json]) - end - - # --- Conflict --- - - def test_conflict_returns_409 - formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - result = formatter.Conflict(errors: { base: ["already exists"] }) - assert_equal :conflict, result[:status] - end - - def test_conflict_wraps_errors_in_envelope - formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - result = formatter.Conflict(errors: { base: ["already exists"] }) - assert_equal({ data: nil, errors: { base: ["already exists"] }, status: "error" }, result[:json]) - end - - # --- PaymentRequired --- - - def test_payment_required_returns_402 - formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - result = formatter.PaymentRequired(errors: { base: ["subscription overdue"] }) - assert_equal :payment_required, result[:status] - end - - def test_payment_required_wraps_errors_in_envelope - formatter = Object.new.extend(ActionFigure::Formatters::Wrapped) - result = formatter.PaymentRequired(errors: { base: ["subscription overdue"] }) - assert_equal({ data: nil, errors: { base: ["subscription overdue"] }, status: "error" }, result[:json]) + assert_equal :conflict, formatter.error_response(errors: {}, status: :conflict)[:status] + assert_equal :gone, formatter.error_response(errors: {}, status: :gone)[:status] end end diff --git a/test/action_figure/testing/minitest_test.rb b/test/action_figure/testing/minitest_test.rb index 1dc1a6c..9797297 100644 --- a/test/action_figure/testing/minitest_test.rb +++ b/test/action_figure/testing/minitest_test.rb @@ -119,12 +119,12 @@ def call = Ok(resource: {}) class MinitestStatusRegistryTest < Minitest::Test include ActionFigure::Testing::Minitest - # Locks the full generated set: every STATUSES entry gets a working assert_* + # Locks the full generated set: every statuses entry gets a working assert_* # and refute_*, including Conflict / PaymentRequired / NoContent. - ActionFigure::Testing::STATUSES.each do |name, status| + ActionFigure::Testing.statuses.each do |name, status| define_method(:"test_assert_and_refute_#{name}") do - assert_send([self, :"assert_#{name}", { status: status }]) - assert_send([self, :"refute_#{name}", { status: :some_other_status }]) + send(:"assert_#{name}", { status: status }) + send(:"refute_#{name}", { status: :some_other_status }) end end end @@ -159,6 +159,22 @@ def call = Ok(resource: {}) end end +class MinitestNewBuiltinStatusTest < Minitest::Test + include ActionFigure::Testing::Minitest + + def test_new_builtin_status_assertions_exist + assert_Gone({ status: :gone }) + assert_Locked({ status: :locked }) + assert_UnavailableForLegalReasons({ status: :unavailable_for_legal_reasons }) + end + + def test_new_builtin_negated_assertions_exist + refute_Gone({ status: :ok }) + refute_Locked({ status: :ok }) + refute_UnavailableForLegalReasons({ status: :ok }) + end +end + class MinitestJsonAssertionTest < Minitest::Test include ActionFigure::Testing::Minitest diff --git a/test/action_figure/testing/rspec_test.rb b/test/action_figure/testing/rspec_test.rb index e0a1212..9a1e66a 100644 --- a/test/action_figure/testing/rspec_test.rb +++ b/test/action_figure/testing/rspec_test.rb @@ -137,6 +137,19 @@ def call(params:) = Ok(resource: params) end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /:name/) end + it "matches the new built-in Gone status" do + expect({ status: :gone }).to be_Gone + expect({ status: :ok }).not_to be_Gone + end + + it "matches the new built-in Locked status" do + expect({ status: :locked }).to be_Locked + end + + it "matches the new built-in UnavailableForLegalReasons status" do + expect({ status: :unavailable_for_legal_reasons }).to be_UnavailableForLegalReasons + end + it "be_* fails clearly when given a non-result value" do expect do expect("nope").to be_Ok