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
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
- '3.2'
- '3.3'
- '3.4'
- '4.0'

steps:
- uses: actions/checkout@v6
Expand Down
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
1 change: 1 addition & 0 deletions action_figure.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 32 additions & 34 deletions docs/custom-formatters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
5 changes: 4 additions & 1 deletion docs/response-formatters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---------------------------------|--------------------------|--------------------------------------------------|
Expand All @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions docs/status-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.
8 changes: 7 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)`) |
Expand Down
6 changes: 5 additions & 1 deletion lib/action_figure.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

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