From f7c12b39d4eaefb00840c5d501118a09355c2a69 Mon Sep 17 00:00:00 2001 From: Tad Thorley Date: Thu, 2 Jul 2026 21:32:55 -0600 Subject: [PATCH 1/2] Prepare 0.7.0 release: fix rfc_9457 as: dasherization, stale docs, version bump - rfc_9457: as: symbols with underscores now dasherize consistently (as: :user_profile -> type "user-profile-ok", title "User profile ok") - rfc_9457: drop Integer/Float from PRIMITIVE_CLASSES (covered by Numeric) - Bump version to 0.7.0 and update Gemfile.lock; fold CHANGELOG [Unreleased] into [0.7.0] dated 2026-07-02 (the 2026-06-29 entry never shipped) - response-formatters.md: Default format docs described the pre-0.5.0 unwrapped-body behavior; rewritten to match the { data: } envelope - README.md: JSON:API example type is singular ("user", not "users") - custom-formatters.md: generated helpers delegate with errors: nil and forward **extras; align intro with the 0.7 contract note - action_figure.rb: resolve deprecation-comment placeholder (remove in 0.8.0) - rfc_9457_test.rb: remove stale stub comment --- CHANGELOG.md | 20 ++-------- Gemfile.lock | 4 +- README.md | 2 +- docs/custom-formatters.md | 4 +- docs/response-formatters.md | 38 +++++++++++-------- lib/action_figure.rb | 2 +- lib/action_figure/formatters/rfc_9457.rb | 7 +++- lib/action_figure/version.rb | 2 +- .../action_figure/formatters/rfc_9457_test.rb | 12 ++++-- 9 files changed, 47 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 990bdd6..0e0e084 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,27 +18,15 @@ 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. - -### 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 - - **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.) +- **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`**. ### 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..7fbe2a6 100644 --- a/lib/action_figure.rb +++ b/lib/action_figure.rb @@ -23,7 +23,7 @@ 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. + # Remove in 0.8.0. IndeterminantEntryPointError = IndeterminateEntryPointError deprecate_constant :IndeterminantEntryPointError 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/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] From b7408eca723870548c0eef282870723a43e4020e Mon Sep 17 00:00:00 2001 From: Tad Thorley Date: Thu, 2 Jul 2026 21:40:55 -0600 Subject: [PATCH 2/2] Remove deprecated IndeterminantEntryPointError alias Misspelled constant shipped through 0.6.0, aliased with deprecate_constant in 0.6.1 for one release; 0.7.0 removes it. Drops the alias, its RBS signature, and the alias resolution test. --- CHANGELOG.md | 4 ++++ lib/action_figure.rb | 5 ----- sig/action_figure.rbs | 3 --- test/action_figure/core_test.rb | 15 --------------- 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e0e084..e03a360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ All notable changes to ActionFigure will be documented in this file. - 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 + +- 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 - There is **no** formatter-agnostic assertion for **non-validation** error bodies (e.g. **`NotFound`**/**`Conflict`** with a custom **`errors:`** payload). Each formatter stores errors differently (**`json[:errors]`** vs **`json[:data]`** vs a JSON:API array) and the result hash carries no formatter identity. Assert those with a format-specific **`assert_action_json`** / **`have_action_json`**. Validation errors are best tested via the contract assertions above. diff --git a/lib/action_figure.rb b/lib/action_figure.rb index 7fbe2a6..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 0.8.0. - 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/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]