Skip to content
Draft
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

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

## [Unreleased]

### Added

- **`request_schema`** class macro — a location-aware alternative to `params_schema`. Declares the HTTP request in OpenAPI's vocabulary via **`path`**/**`query`**/**`body`** locations. The block form declares; the no-args form returns the compiled **`ActionFigure::RequestSchema`** (per-location coercing contracts via `.contracts`, typed value construction), mirroring the `api_version` block/reader duality. `params_schema` is unchanged and not deprecated; an action class declares one or the other, never both.
- Class-load guards for `request_schema`, failing at boot with pointed messages:
- declaring both `params_schema` and `request_schema` (either order) raises `ArgumentError`
- duplicate `request_schema` raises `ArgumentError` (parity with the `params_schema` guard)
- `optional(...)` inside a `path` location raises — OpenAPI path parameters are always required
- schema keys named **`given?`**, **`given_keys`**, **`to_h`**, or **`deconstruct_keys`** (at any nesting depth, array members included) raise — these names are reserved by the typed request values
- bare `required`/`optional` declarations outside a location raise with guidance instead of `NoMethodError`
- a duplicate location block (`body { ... }` twice) raises instead of silently overwriting the first
- a location called without a block (`path` bare) raises instead of compiling to an empty schema
- `rules(:location)` declared above the `request_schema` block raises pointing at the declaration order
- dry-schema's **`:info`** extension is now loaded (powers the class-load guards).
- **`request:` invocation pipeline.** `request_schema` actions are called with `request:` (the Rails `ActionDispatch::Request` — duck-typed on `path_parameters`/`query_parameters`/`request_parameters`, so core stays framework-free) instead of `params:`. Each location validates against its actual source: a query-location key arriving in the body is not seen, and same-named keys in different locations are distinct parameters. Controllers: `render Action.update(request:, current_user:)`.
- **Typed request values.** The action method receives `request:` as a frozen, schema-shaped value (`Data`-based, generated once at class load): `request.path.id`, `request.query.workspace_id`, `request.body.name` — coerced and validated; typo'd key access raises `NoMethodError` at the call site. Actions declare only the locations they have.
- **Absent vs. explicit nil is preserved** (PATCH semantics): reads return `nil` for both, **`given?(:key)`** tells them apart, pattern matching (`in { description: }`) matches only keys the client actually sent, and **`to_h`** returns given keys only — matching dry-schema's key-omission behavior.
- **Nested typed values.** Nested hash schemas become nested frozen values (`request.body.project.settings.visibility`) and arrays of hashes become arrays of typed values — with `given?`/`given_keys` at every level. Typed exactly as deep as the contract is explicit: a blockless `hash` (free-form JSON) stays a plain hash. **`to_h`** returns given keys as plain hashes all the way down (safe for `Model.update(request.body.to_h)`); pattern matching binds typed values.
- **`ActionFigure.request(path:, query:, body:)`** — request stand-in for invoking `request_schema` actions from tests and consoles.
- **Path-location failures render `NotFound`** (a malformed identity param means the resource cannot exist; identity wins on mixed failures); other validation failures render `UnprocessableContent` with the familiar flat `{field => [messages]}` errors, merged across locations — same-named keys failing in multiple locations concatenate their messages.
- **`whiny_extra_params` applies to `request_schema` actions** per location: undeclared keys in `query`/`body` return `422` with `"is not allowed"` errors. The `path` location is exempt (router-defined keys plus `:controller`/`:action`/`:format` bookkeeping). Undeclared locations are never read off the request — a `path`/`query`-only schema never triggers body parsing.
- Passing `params:` (or any non-request object) to a `request_schema` action raises `ArgumentError` pointing to `request:` / `ActionFigure.request` — no silent fallback to merged validation.
- **Location-aware contract assertions.** For `request_schema` actions, the contract helpers take locations: Minitest **`assert_valid_params(Action, query: {...}, body: {...})`** / **`assert_invalid_params(..., on: :field)`**; RSpec **`accept_params(query: {...}, body: {...})`** / **`reject_params(...).with_error_on(:field)`**. Omitted locations validate as empty (matching runtime); unknown location names raise listing the declared locations; errors report flat across locations. Omitting params entirely, or mixing a positional params hash with location keywords (including typo'd keywords like `om:`), raises `ArgumentError`. The existing hash forms for `params_schema` actions are unchanged.
- **Location-scoped `rules` for `request_schema`**: `rules(:body) { ... }`, `rules(:query) { ... }`, `rules(:path) { ... }` — one block per declared location, attaching to that location's contract with today's semantics (cross-param helpers included). Path-location rules failures render 404 like path schema failures; others 422. Guards at class load: bare `rules` on a `request_schema` action, rules for an undeclared location, duplicate `rules(location)`, and `rules(:location)` on a `params_schema` action all raise with pointed messages. Cross-location rules live in the method body.

## [0.7.0] - 2026-07-02

### Added
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ 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`. |
| [Request Schema](docs/request-schema.md) | Location-aware validation in OpenAPI's vocabulary: declare where each param arrives (`path`/`query`/`body`) and it's enforced. Actions receive a frozen, typed request value (`request.body.name`, `given?` for PATCH semantics); malformed path params render 404. |
| [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). |
Expand Down Expand Up @@ -177,6 +178,43 @@ class Search::LookupAction
end
```

### Location-Aware Requests

For endpoints whose request shape is a published contract, `request_schema` declares **where** each param arrives — and enforces it. The action receives a frozen, typed request value:

```ruby
class Projects::UpdateAction
include ActionFigure[:jsend]

request_schema do
path { required(:id).filled(:integer) }
query { required(:workspace_id).filled(:integer) }
body do
required(:name).filled(:string)
optional(:description).maybe(:string)
end
end

def update(request:, current_user:)
project = current_user.projects.find(request.path.id) # coerced integer
project.update(name: request.body.name)
# PATCH semantics: only touch fields the client actually sent
project.update(description: request.body.description) if request.body.given?(:description)

Ok(resource: ProjectBlueprint.render_as_hash(project))
end
end
```

```ruby
# controller
def update
render Projects::UpdateAction.update(request:, current_user:)
end
```

A query param sent in the body is not seen; a malformed path param renders 404. See [Request Schema](docs/request-schema.md).

### Response Formatters

Choose a response envelope by name. The same helpers return different shapes:
Expand Down
Loading