Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

All notable changes to ActionFigure will be documented in this file.

## [Unreleased]
## [0.7.0] - 2026-07-02

### Added

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions docs/custom-formatters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 23 additions & 15 deletions docs/response-formatters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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"
}
}
```

Expand All @@ -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:)
Expand All @@ -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])
Expand All @@ -155,7 +159,9 @@ end
```

```json
{}
{
"data": null
}
```

**`Accepted` -- with a resource:**
Expand All @@ -170,8 +176,10 @@ end

```json
{
"order_id": 7,
"status": "processing"
"data": {
"order_id": 7,
"status": "processing"
}
}
```

Expand Down Expand Up @@ -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:`:

Expand Down
5 changes: 0 additions & 5 deletions lib/action_figure.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions lib/action_figure/formatters/rfc_9457.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/action_figure/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module ActionFigure
VERSION = "0.6.2"
VERSION = "0.7.0"
end
3 changes: 0 additions & 3 deletions sig/action_figure.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 0 additions & 15 deletions test/action_figure/core_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 8 additions & 4 deletions test/action_figure/formatters/rfc_9457_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down