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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ All notable changes to ActionFigure will be documented in this file.
- 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.
- `:rfc_9457` formatter implementing RFC 9457 Problem Details for HTTP APIs.
Errors render as `application/problem+json` with `type`, `title`, `status`,
`detail`, `instance`, and extension members. Success responses use the same
vocabulary (`type`, `title`, named resource key). Defaults derive from class
names and status symbols; all members accept override kwargs.
- Generated error helpers now accept `errors: nil` (previously required) and
forward `**extras` to `error_response`. Existing formatters are unaffected
when called without extras.

### Changed

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ Every action class has three responsibilities:
| Feature | Description |
|---------|-------------|
| [Validation](docs/validation.md) | Two-layer validation powered by dry-validation: structural schemas with type coercion, plus validation rules. Includes cross-parameter helpers like `exclusive_rule`, `any_rule`, `one_rule`, and `all_rule`. |
| [Response Formatters](docs/response-formatters.md) | Four built-in formats: Default, JSend, JSON:API, and Wrapped. Each provides response helpers (`Ok`, `Created`, `NotFound`, etc.) that return render-ready hashes. |
| [Response Formatters](docs/response-formatters.md) | Five built-in formats: Default, JSend, JSON:API, Wrapped, and RFC 9457. Each provides response helpers (`Ok`, `Created`, `NotFound`, etc.) that return render-ready hashes. |
| [Problem Details](docs/problem-details.md) | RFC 9457 formatter (`:rfc_9457`) that renders errors as `application/problem+json` problem documents with `type`, `title`, `status`, `detail`, and `instance` members. Success responses mirror the same vocabulary. |
| [Status Codes](docs/status-codes.md) | Which 4xx codes are domain concerns (handled by action classes) vs perimeter concerns (handled by middleware, router, or infrastructure). |
| [Custom Formatters](docs/custom-formatters.md) | Define your own response envelope by implementing the formatter interface. Registration validates your module at load time. |
| [Actions](docs/actions.md) | Automatic entry point discovery, context injection via keyword arguments, per-class API versioning, and `entry_point` for disambiguation. |
Expand Down
2 changes: 2 additions & 0 deletions docs/custom-formatters.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ ActionFigure::Formatter::REQUIRED_METHODS

**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.

**`error_response` contract update (0.7+):** Generated error helpers now call `error_response` with `errors: nil` (previously `errors:` was required and always provided). If your formatter declares `error_response(errors:, status:)` strictly, it will continue to work for calls that pass `errors:`. However, if your formatter wants to support pass-through kwargs such as `detail:`, `instance:`, or custom extension members (as the `:rfc_9457` formatter does), declare the signature as `error_response(errors: nil, status:, **extras)` and forward `**extras` in your implementation. A strict formatter that declares only `(errors:, status:)` will raise `ArgumentError` when a caller passes extra kwargs — this is documented-correct behavior, not a bug. Only add `**extras` if your formatter intends to handle or forward those members.

If any required method is missing, registration raises an `ArgumentError` that lists exactly which methods are absent:

```ruby
Expand Down
113 changes: 113 additions & 0 deletions docs/problem-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Problem Details (RFC 9457)

## Overview

The `:rfc_9457` formatter renders errors as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents with `Content-Type: application/problem+json`. Success responses mirror the same `type`/`title` vocabulary, though RFC 9457 itself only specifies error documents.

```ruby
class Projects::FindAction
include ActionFigure[:rfc_9457]

def show(params:)
project = Project.find_by(id: params[:id])
return NotFound(
errors: { id: ["not found"] },
type: "https://api.example.com/problems/project-not-found",
title: "Project not found",
detail: "No project with id #{params[:id]} exists.",
instance: "/projects/#{params[:id]}"
) unless project

Ok(resource: project, type: "project-found", title: "Project found")
end
end
```

## Error Responses

Error helpers (`NotFound`, `Conflict`, etc.) produce a problem document:

| Member | Default | Override kwarg |
|------------|------------------------------------------------------------|----------------|
| `type` | `"<action-class>-<status>-error"`; `UnprocessableContent` uses `"unprocessable-content-error"` (see Defaults below) | `type:` |
| `title` | HTTP status phrase, e.g. `"Not Found"` | `title:` |
| `status` | Numeric HTTP code, e.g. `404` | — |
| `detail` | Omitted | `detail:` |
| `instance` | Omitted | `instance:` |
| `errors` | Extension member; omitted when nil | `errors:` |

Any additional kwargs become extension members.

The render hash includes `content_type: "application/problem+json"`. Rails honors this directly; a plain Rack app can read the key.

### Defaults

`UnprocessableContent` has a fixed default type of `"unprocessable-content-error"`. The framework calls this helper automatically on schema validation failure, so a stable type is provided without configuration:

```ruby
# Schema failure — type is already "unprocessable-content-error":
UnprocessableContent(errors: result.errors.to_h)

# Override when you want a domain-specific URI:
UnprocessableContent(
errors: user.errors.messages,
type: "https://api.example.com/problems/validation-error",
title: "Validation failed"
)
```

All other error helpers derive `type` mechanically from the action class and HTTP status — `Projects::FindAction` + `NotFound` → `"projects-find-not-found-error"`. This is intentionally awkward. The RFC recommends a stable URI, and your API clients deserve one:

```ruby
# Awkward default — a signal to replace it:
# type: "projects-find-not-found-error"

# What you should write instead:
NotFound(
type: "https://api.example.com/problems/project-not-found",
title: "Project not found",
errors: { id: ["not found"] }
)
```

## Success Responses

Success helpers (`Ok`, `Created`, `Accepted`) build a mirrored vocabulary:

| Member | Default | Override kwarg |
|-------------------|----------------------------------------------------|----------------|
| `type` | `"<resource-name>-<status>"` (see below) | `type:` |
| `title` | `"<Resource name> <status>"` (see below) | `title:` |
| `<resource-key>` | Resource under its class-derived name or `data` | `as:` |
| `meta` | Omitted when nil | `meta:` |

The resource key and name derive from the resource's class:

```ruby
Created(resource: user) # User instance → key :user
# type: "user-created", title: "User created", user: { ... }

Created(resource: some_hash) # Hash → key :data
# type: "resource-created", title: "Resource created", data: { ... }

Created(resource: h, as: :project) # explicit override
# type: "project-created", title: "Project created", project: { ... }
```

`as:`, `type:`, and `title:` can be used independently — `as:` drives the resource key and the derived defaults; `type:` and `title:` override only their own member.

Trailing `Action` is stripped from class names: `Projects::CreateAction` → `"projects-create"`.

`Ok(resource: user)` defaults to `type: "user-ok"` / `title: "User ok"` — deliberately awkward for the same reason as error defaults. Pass `type:` and `title:` to give your clients something meaningful.

Success responses use plain `application/json` (no `content_type:` key).

`NoContent` returns `{ status: :no_content }` with no body — inherited from the base formatter.

## Registering Additional Error Statuses

`ActionFigure.register_error` works the same way as with any other formatter. Registered error helpers automatically use `error_response`, so they produce problem documents in `:rfc_9457` action classes without any extra configuration.

## Custom `error_response` Contract

If you write a custom formatter that you want to compose with RFC 9457 style, note that `error_response` now receives `errors: nil, status:, **extras`. Accept `**extras` to let `detail:`, `instance:`, and extension members flow through.
159 changes: 157 additions & 2 deletions docs/response-formatters.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class UsersController < ApplicationController
end
```

The **formatter** determines the shape of the JSON envelope wrapping your data. ActionFigure ships with four built-in formatters: Default, JSend, JSON:API, and Wrapped.
The **formatter** determines the shape of the JSON envelope wrapping your data. ActionFigure ships with five built-in formatters: Default, JSend, JSON:API, Wrapped, and RFC 9457.

## Choosing a Format

Expand All @@ -39,6 +39,11 @@ class Users::CreateAction
include ActionFigure[:wrapped]
end

# Explicit RFC 9457
class Users::CreateAction
include ActionFigure[:rfc_9457]
end

# Uses the configured default (Default unless changed)
class Users::CreateAction
include ActionFigure
Expand Down Expand Up @@ -988,9 +993,159 @@ end
}
```

## RFC 9457 Format

The RFC 9457 formatter renders errors as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents (`Content-Type: application/problem+json`). Success responses use the same `type`/`title` vocabulary, though RFC 9457 itself only covers errors.

### Success Responses

Success responses place the resource under a key derived from its class name and carry `type` and `title` members.

**`Created` -- returning a model resource:**

```ruby
def call(params:)
user = User.create(params)
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?

Created(
resource: user,
type: "https://api.example.com/success/user-created",
title: "User created"
)
end
```

```json
{
"type": "https://api.example.com/success/user-created",
"title": "User created",
"user": { "id": 42, "name": "Jane Doe", "email": "jane@example.com" }
}
```

Without explicit `type:` and `title:`, the formatter derives them from the resource class and status — `User` + `Created` → `type: "user-created"`, `title: "User created"`. For `Ok`, `User` + `Ok` → `type: "user-ok"`, `title: "User ok"` — deliberately awkward. Pass explicit values your clients can rely on.

Hash and primitive resources fall back to the key `data` and the name `resource`:

```ruby
Created(resource: { order_id: 7, status: "queued" })
# -> type: "resource-created", title: "Resource created", data: { ... }
```

Override the derived name with `as:`:

```ruby
Created(resource: some_hash, as: :order)
# -> type: "order-created", title: "Order created", order: { ... }
```

**`Accepted` -- with no resource:**

```ruby
def call(params:)
OrderFulfillmentJob.perform_later(params[:order_id])
Accepted()
end
```

```json
{
"type": "resource-accepted",
"title": "Resource accepted"
}
```

### Failure Responses

Failure responses produce RFC 9457 problem documents. All members except `type`, `title`, and `status` are optional.

**`UnprocessableContent` -- validation errors:**

`UnprocessableContent` defaults to `type: "unprocessable-content-error"`, so schema validation failures work without any configuration:

```ruby
def call(params:)
user = User.new(params)
return UnprocessableContent(errors: user.errors.messages) unless user.save
resource = UserBlueprint.render_as_hash(user)
Created(resource:, type: "https://api.example.com/success/user-created", title: "User created")
end
```

```json
{
"type": "unprocessable-content-error",
"title": "Unprocessable Content",
"status": 422,
"errors": {
"email": ["has already been taken"],
"name": ["can't be blank"]
}
}
```

Override `type:` and `title:` when you want a domain-specific URI:

```ruby
UnprocessableContent(
errors: user.errors.messages,
type: "https://api.example.com/problems/validation-error",
title: "Validation failed"
)
```

**`NotFound` -- with `detail` and `instance`:**

```ruby
def call(params:)
user = User.find_by(id: params[:id])
return NotFound(
type: "https://api.example.com/problems/user-not-found",
title: "User not found",
detail: "No user with id #{params[:id]} exists.",
instance: "/users/#{params[:id]}"
) unless user
Ok(resource: user, type: "https://api.example.com/success/user-ok", title: "User found")
end
```

```json
{
"type": "https://api.example.com/problems/user-not-found",
"title": "User not found",
"status": 404,
"detail": "No user with id 99 exists.",
"instance": "/users/99"
}
```

Extra kwargs become extension members in the problem document:

```ruby
PaymentRequired(
type: "https://api.example.com/problems/quota-exceeded",
title: "Quota exceeded",
balance: 0,
limit: 100
)
```

```json
{
"type": "https://api.example.com/problems/quota-exceeded",
"title": "Quota exceeded",
"status": 402,
"balance": 0,
"limit": 100
}
```

`UnprocessableContent` defaults to `type: "unprocessable-content-error"`. All other error helpers derive `type` from the action class and status — `Projects::FindAction` + `NotFound` → `"projects-find-not-found-error"`. Provide a stable URI; the default is intentionally unattractive.

## 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 four 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. In the default formatter, providing `meta:` wraps the response in `{ "data": resource, "meta": meta }` — without `meta:`, the resource is the entire body.

Common uses for `meta:`:

Expand Down
2 changes: 2 additions & 0 deletions lib/action_figure.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
require_relative "action_figure/formatters/json_api"
require_relative "action_figure/formatters/default"
require_relative "action_figure/formatters/wrapped"
require_relative "action_figure/formatters/rfc_9457"

# ActionFigure provides explicit, purpose-driven operation classes for Rails controller actions.
module ActionFigure
Expand All @@ -35,6 +36,7 @@ class InitializationNotSupportedError < StandardError; end
register_formatter(jsonapi: Formatters::JsonApi)
register_formatter(default: Formatters::Default)
register_formatter(wrapped: Formatters::Wrapped)
register_formatter(rfc_9457: Formatters::Rfc9457) # rubocop:disable Naming/VariableNumber

def self.[](format = configuration.format)
format_modules.compute_if_absent(format) { build_format_module(format, fetch(format)) }
Expand Down
4 changes: 2 additions & 2 deletions lib/action_figure/error_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ module ErrorRegistry
module Helpers; end

def self.define_helper(name, status_symbol)
Helpers.define_method(name) do |errors:|
error_response(errors: errors, status: status_symbol)
Helpers.define_method(name) do |errors: nil, **extras|
error_response(errors: errors, status: status_symbol, **extras)
end
end

Expand Down
Loading