diff --git a/CHANGELOG.md b/CHANGELOG.md index 990bdd6..e03a360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to ActionFigure will be documented in this file. -## [Unreleased] +## [0.7.0] - 2026-07-02 ### Added @@ -18,28 +18,20 @@ All notable changes to ActionFigure will be documented in this file. - Generated error helpers now accept `errors: nil` (previously required) and forward `**extras` to `error_response`. Existing formatters are unaffected when called without extras. +- **Contract assertions** — test an action's **`params_schema`**/**`rules`** in isolation, without invoking the action body. Minitest: **`assert_valid_params(action_class, params)`** and **`assert_invalid_params(action_class, params, on: :field)`**. RSpec: **`accept_params(params)`** and **`reject_params(params).with_error_on(:field)`** (subject is the action class). These are formatter-agnostic. Both raise a clear **`ArgumentError`** for actions without a **`params_schema`**. +- **`assert_action_json`** / **`refute_action_json`** Minitest assertions — partial match on **`result[:json]`**, mirroring the RSpec **`have_action_json`** matcher. Nested Hashes match as subsets and **`Regexp`** values match against strings. +- Negated Minitest status assertions: **`refute_Ok`**, **`refute_Created`**, … for every status (parity with RSpec **`not_to be_Ok`**). ### 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. +- Status helpers for both adapters are now generated from the live registry (**`ActionFigure::Testing.statuses`**, including **`NoContent`**), so the Minitest and RSpec lists can no longer drift and newly registered statuses appear automatically. Replaces the RSpec-only **`ActionFigure::Testing::RSpec::MATCHERS`** constant. +- Status assertions/matchers now fail with a clear "expected an ActionFigure result hash" message when given a non-Hash, instead of raising **`NoMethodError`**. ### 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 - -- **Contract assertions** — test an action's **`params_schema`**/**`rules`** in isolation, without invoking the action body. Minitest: **`assert_valid_params(action_class, params)`** and **`assert_invalid_params(action_class, params, on: :field)`**. RSpec: **`accept_params(params)`** and **`reject_params(params).with_error_on(:field)`** (subject is the action class). These are formatter-agnostic. Both raise a clear **`ArgumentError`** for actions without a **`params_schema`**. -- **`assert_action_json`** / **`refute_action_json`** Minitest assertions — partial match on **`result[:json]`**, mirroring the RSpec **`have_action_json`** matcher. Nested Hashes match as subsets and **`Regexp`** values match against strings. -- Negated Minitest status assertions: **`refute_Ok`**, **`refute_Created`**, … for every status (parity with RSpec **`not_to be_Ok`**). - -### Changed - -- 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`**. +- The deprecated `IndeterminantEntryPointError` alias (misspelled constant shipped through 0.6.0, aliased in 0.6.1). Use **`IndeterminateEntryPointError`**; update any remaining `rescue` clauses. ### Known limitation diff --git a/Gemfile.lock b/Gemfile.lock index c0c1cce..1bc4760 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - action_figure (0.6.2) + action_figure (0.7.0) concurrent-ruby (>= 1.0) dry-validation (~> 1.10) rack (>= 2.2) @@ -233,7 +233,7 @@ DEPENDENCIES sqlite3 (~> 2.0) CHECKSUMS - action_figure (0.6.2) + action_figure (0.7.0) actionpack (8.1.3) sha256=af998cae4d47c5d581a2cc363b5c77eb718b7c4b45748d81b1887b25621c29a3 actionview (8.1.3) sha256=1347c88c7f3edb38100c5ce0e9fb5e62d7755f3edc1b61cce2eb0b2c6ea2fd5d activemodel (8.1.3) sha256=90c05cbe4cef3649b8f79f13016191ea94c4525ce4a5c0fb7ef909c4b91c8219 diff --git a/README.md b/README.md index 5da6ffd..451b767 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Created(resource: user) # JSON:API Created(resource: user) -# => { json: { data: { type: "users", id: "1", attributes: user } }, status: :created } +# => { json: { data: { type: "user", id: "1", attributes: user } }, status: :created } # Wrapped Created(resource: user) diff --git a/docs/custom-formatters.md b/docs/custom-formatters.md index 7d70eab..e85dd3f 100644 --- a/docs/custom-formatters.md +++ b/docs/custom-formatters.md @@ -25,9 +25,9 @@ The required methods are: `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. -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. +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:, **extras)` — `errors:` is `nil` when the caller doesn't provide one, and any extra kwargs are forwarded. 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. +`error_response` receives the keyword arguments `errors:` (an error hash, or `nil`) and `status:` (a Rack status symbol), plus any pass-through kwargs the caller supplied, and must return a hash. See the contract update note under [Interface Validation](#interface-validation) for the recommended signature. ## Building a Custom Formatter diff --git a/docs/response-formatters.md b/docs/response-formatters.md index af741f4..8c412c7 100644 --- a/docs/response-formatters.md +++ b/docs/response-formatters.md @@ -75,11 +75,11 @@ ActionFigure provides helpers for the status codes most commonly returned by act ## Default Format -The default formatter produces Rails-style responses: the resource is the top-level JSON on success, and errors live under an `"errors"` key on failure. This is the configured default format — bare `include ActionFigure` uses it unless you change `config.format`. +The default formatter produces Rails-style responses: the resource lives under a `"data"` key on success, and errors live under an `"errors"` key on failure. This is the configured default format — bare `include ActionFigure` uses it unless you change `config.format`. ### Success Responses -The resource you pass becomes the entire JSON body with no wrapper. +The resource you pass is wrapped in a `{ "data": ... }` envelope. **`Ok` -- returning a single resource:** @@ -93,9 +93,11 @@ end ```json { - "id": 1, - "name": "Jane Doe", - "email": "jane@example.com" + "data": { + "id": 1, + "name": "Jane Doe", + "email": "jane@example.com" + } } ``` @@ -113,15 +115,17 @@ end ```json { - "id": 42, - "name": "Jane Doe", - "email": "jane@example.com" + "data": { + "id": 42, + "name": "Jane Doe", + "email": "jane@example.com" + } } ``` **`Ok` -- with metadata:** -When `meta:` is provided, the response wraps the resource under a `"data"` key so that `"meta"` can sit alongside it: +When `meta:` is provided, `"meta"` sits alongside `"data"`: ```ruby def call(params:) @@ -143,10 +147,10 @@ end } ``` -Without `meta:`, the resource is the entire body. With `meta:`, the response becomes `{ "data": resource, "meta": meta }`. - **`Accepted` -- with no resource:** +Without a resource, `"data"` is `null` (the key is always present): + ```ruby def call(params:) OrderFulfillmentJob.perform_later(params[:order_id]) @@ -155,7 +159,9 @@ end ``` ```json -{} +{ + "data": null +} ``` **`Accepted` -- with a resource:** @@ -170,8 +176,10 @@ end ```json { - "order_id": 7, - "status": "processing" + "data": { + "order_id": 7, + "status": "processing" + } } ``` @@ -1145,7 +1153,7 @@ PaymentRequired( ## The `meta:` Keyword -The `meta:` keyword argument is available on `Ok`, `Created`, and `Accepted`. It accepts any hash, which is included as a top-level `"meta"` key in all five formatters. When `meta:` is `nil` (the default), the key is omitted entirely from the response. In the default formatter, providing `meta:` wraps the response in `{ "data": resource, "meta": meta }` — without `meta:`, the resource is the entire body. +The `meta:` keyword argument is available on `Ok`, `Created`, and `Accepted`. It accepts any hash, which is included as a top-level `"meta"` key in all five formatters. When `meta:` is `nil` (the default), the key is omitted entirely from the response. Common uses for `meta:`: diff --git a/lib/action_figure.rb b/lib/action_figure.rb index 0af1c59..620ce48 100644 --- a/lib/action_figure.rb +++ b/lib/action_figure.rb @@ -22,11 +22,6 @@ module ActionFigure class IndeterminateEntryPointError < StandardError; end - # Backwards-compatible alias for the misspelled constant shipped through 0.6.0. - # Remove in the next minor release after Unreleased. - IndeterminantEntryPointError = IndeterminateEntryPointError - deprecate_constant :IndeterminantEntryPointError - # Raised when an action class defines +initialize+. ActionFigure builds instances with # +new+ and passes no constructor arguments; use keyword arguments on the entry method # or class-level state instead of custom initializers. diff --git a/lib/action_figure/formatters/rfc_9457.rb b/lib/action_figure/formatters/rfc_9457.rb index 0f8f9e7..4a3d468 100644 --- a/lib/action_figure/formatters/rfc_9457.rb +++ b/lib/action_figure/formatters/rfc_9457.rb @@ -49,7 +49,7 @@ def error_response(status:, errors: nil, type: nil, title: nil, detail: nil, ins end # rubocop:enable Metrics/ParameterLists, Metrics/AbcSize - PRIMITIVE_CLASSES = [Hash, Array, String, Numeric, Integer, Float, Symbol, + PRIMITIVE_CLASSES = [Hash, Array, String, Numeric, Symbol, TrueClass, FalseClass, NilClass].freeze private @@ -92,15 +92,18 @@ def action_name_for_error dasherize_class_name(klass_name) end - # Converts a Ruby class name string to a dasherized, Action-stripped slug. + # Converts a Ruby class name string (or an as: symbol) to a dasherized, + # Action-stripped slug. # Examples: # "User" -> "user" # "Admin::UserProfile" -> "admin-user-profile" # "Projects::CreateAction" -> "projects-create" + # "user_profile" -> "user-profile" def dasherize_class_name(name) name .sub(/Action\z/, "") # strip trailing "Action" .gsub("::", "-") # namespace separator -> dash + .tr("_", "-") # snake_case as: symbols -> dashes .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2') # e.g. XMLParser -> XML-Parser .gsub(/([a-z\d])([A-Z])/, '\1-\2') # camelCase -> camel-Case .downcase diff --git a/lib/action_figure/version.rb b/lib/action_figure/version.rb index df83c14..ffc398d 100644 --- a/lib/action_figure/version.rb +++ b/lib/action_figure/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ActionFigure - VERSION = "0.6.2" + VERSION = "0.7.0" end diff --git a/sig/action_figure.rbs b/sig/action_figure.rbs index de443d7..368c40b 100644 --- a/sig/action_figure.rbs +++ b/sig/action_figure.rbs @@ -12,9 +12,6 @@ module ActionFigure class IndeterminateEntryPointError < StandardError end - # Deprecated alias for IndeterminateEntryPointError (misspelled constant from <= 0.6.0). - IndeterminantEntryPointError: Class - class InitializationNotSupportedError < StandardError end diff --git a/test/action_figure/core_test.rb b/test/action_figure/core_test.rb index 9574a2a..589f55d 100644 --- a/test/action_figure/core_test.rb +++ b/test/action_figure/core_test.rb @@ -599,21 +599,6 @@ def update(params:) assert_match(/entry_point/, error.message) end - def test_deprecated_indeterminant_alias_resolves_to_indeterminate - silence_constant_deprecation_warning do - assert_equal ActionFigure::IndeterminateEntryPointError, - ActionFigure::IndeterminantEntryPointError - end - end - - def silence_constant_deprecation_warning - original_verbose = $VERBOSE - $VERBOSE = nil - yield - ensure - $VERBOSE = original_verbose - end - def test_explicit_entry_point_suppresses_discovery action = Class.new do include ActionFigure[:jsend] diff --git a/test/action_figure/formatters/rfc_9457_test.rb b/test/action_figure/formatters/rfc_9457_test.rb index 40c25e8..72042ea 100644 --- a/test/action_figure/formatters/rfc_9457_test.rb +++ b/test/action_figure/formatters/rfc_9457_test.rb @@ -43,6 +43,13 @@ def test_ok_as_kwarg_overrides_derived_name refute result[:json].key?(:data) end + def test_ok_as_kwarg_with_underscored_name_dasherizes_type_and_title + result = formatter.Ok(resource: { id: 1 }, as: :user_profile) + assert_equal "user-profile-ok", result[:json][:type] + assert_equal "User profile ok", result[:json][:title] + assert_equal({ id: 1 }, result[:json][:user_profile]) + end + def test_ok_type_kwarg_overrides_derived_type result = formatter.Ok(resource: { id: 1 }, type: "my-type") assert_equal "my-type", result[:json][:type] @@ -155,10 +162,7 @@ def test_no_content_has_no_json_body # --- error_response --- def test_error_response_includes_type_title_status_members - f = Object.new.tap do |o| - o.extend(ActionFigure::Formatters::Rfc9457) - # Stub self.class.name so we can test derivation - end + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) result = f.error_response(errors: { id: ["x"] }, status: :not_found) assert_equal "Not Found", result[:json][:title] assert_equal 404, result[:json][:status]